Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,16 @@ command = "notify-send 'Review done for {repo_name} ({sha})'"

Template variables: `{job_id}`, `{repo}`, `{repo_name}`, `{sha}`, `{verdict}`, `{error}`.

For generic JSON webhook endpoints, use the built-in `webhook` hook type to
POST the review event directly:

```toml
[[hooks]]
event = "review.completed"
type = "webhook"
url = "https://example.com/roborev-webhook"
```

### Beads Integration

The built-in `beads` hook type creates [beads](https://github.com/steveyegge/beads) issues
Expand Down
5 changes: 4 additions & 1 deletion cmd/roborev/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,11 @@ Examples:
return fmt.Errorf("stream failed: %s", body)
}

// Stream events - pass through lines directly to preserve all fields
// Stream events - pass through lines directly to preserve all fields.
// Use a 1MB buffer because review.completed events can include
// large findings payloads that exceed the default 64KB limit.
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
if ctx.Err() != nil {
return nil
Expand Down
7 changes: 4 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ func IsConfigParseError(err error) bool {

// HookConfig defines a hook that runs on review events
type HookConfig struct {
Event string `toml:"event"` // "review.failed", "review.completed", "review.*"
Command string `toml:"command"` // shell command with {var} templates
Type string `toml:"type"` // "beads" for built-in, empty for command
Event string `toml:"event"` // "review.failed", "review.completed", "review.*"
Command string `toml:"command"` // shell command with {var} templates
Type string `toml:"type"` // "beads" or "webhook"; empty or "command" runs Command
URL string `toml:"url" sensitive:"true"` // webhook destination URL when Type is "webhook"
}

type AdvancedConfig struct {
Expand Down
6 changes: 6 additions & 0 deletions internal/config/keyval.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ func structType(t reflect.Type) (reflect.Type, bool) {
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() == reflect.Slice {
t = t.Elem()
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
}
return t, t.Kind() == reflect.Struct
}

Expand Down
3 changes: 3 additions & 0 deletions internal/config/keyval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,9 @@ func TestIsSensitiveKey(t *testing.T) {
if IsSensitiveKey("ci.github_app_id") {
t.Error("expected ci.github_app_id to not be sensitive")
}
if !IsSensitiveKey("hooks.url") {
t.Error("expected hooks.url to be sensitive")
}
}

func TestIsGlobalKey(t *testing.T) {
Expand Down
2 changes: 2 additions & 0 deletions internal/daemon/broadcaster.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ func (e Event) MarshalJSON() ([]byte, error) {
SHA string `json:"sha"`
Agent string `json:"agent,omitempty"`
Verdict string `json:"verdict,omitempty"`
Findings string `json:"findings,omitempty"`
Error string `json:"error,omitempty"`
}{
Type: e.Type,
Expand All @@ -130,6 +131,7 @@ func (e Event) MarshalJSON() ([]byte, error) {
SHA: e.SHA,
Agent: e.Agent,
Verdict: e.Verdict,
Findings: e.Findings,
Error: e.Error,
})
}
2 changes: 2 additions & 0 deletions internal/daemon/event_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func TestEvent_MarshalJSON(t *testing.T) {
SHA: "abc123",
Agent: "claude-code",
Verdict: "F",
Findings: "Missing input validation",
}

data, err := event.MarshalJSON()
Expand All @@ -40,6 +41,7 @@ func TestEvent_MarshalJSON(t *testing.T) {
{"sha", "abc123"},
{"agent", "claude-code"},
{"verdict", "F"},
{"findings", "Missing input validation"},
}

if len(decoded) != len(tests) {
Expand Down
82 changes: 82 additions & 0 deletions internal/daemon/hooks.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
package daemon

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
neturl "net/url"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"

"github.com/roborev-dev/roborev/internal/config"
gitpkg "github.com/roborev-dev/roborev/internal/git"
Expand Down Expand Up @@ -144,6 +151,17 @@ func (hr *HookRunner) handleEvent(event Event) {
continue
}

if hook.Type == "webhook" {
if hook.URL == "" {
continue
}

fired++
hr.wg.Add(1)
go hr.postWebhook(hook.URL, event)
continue
}

cmd := resolveCommand(hook, event)
if cmd == "" {
continue
Expand Down Expand Up @@ -270,3 +288,67 @@ func (hr *HookRunner) runHook(command, workDir string) {
hr.logger.Printf("Hook output (cmd=%q): %s", command, output)
}
}

func (hr *HookRunner) postWebhook(webhookURL string, event Event) {
defer hr.wg.Done()
safeURL := redactWebhookURL(webhookURL)

payload, err := json.Marshal(event)
if err != nil {
hr.logger.Printf("Webhook error (url=%q): marshal event: %v", safeURL, err)
return
}

req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(payload))
if err != nil {
hr.logger.Printf("Webhook error (url=%q): build request: %v", safeURL, redactURLError(err))
return
}
req.Header.Set("Content-Type", "application/json")

client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
hr.logger.Printf("Webhook error (url=%q): %v", safeURL, redactURLError(err))
return
}
defer resp.Body.Close()

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
if len(body) > 0 {
hr.logger.Printf("Webhook error (url=%q): status %s: %s", safeURL, resp.Status, strings.TrimSpace(string(body)))
return
}
hr.logger.Printf("Webhook error (url=%q): status %s", safeURL, resp.Status)
}
}

func redactWebhookURL(raw string) string {
parsed, err := neturl.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "<invalid webhook url>"
}

redacted := &neturl.URL{
Scheme: parsed.Scheme,
Host: parsed.Host,
}

if p := parsed.EscapedPath(); p != "" && p != "/" {
redacted.Path = "/..."
}

return redacted.String()
}

// redactURLError unwraps *url.Error to return only its inner
// error, preventing Go's HTTP client from leaking the raw URL
// (including secret path segments) in log output.
func redactURLError(err error) error {
var ue *neturl.Error
if errors.As(err, &ue) {
return ue.Err
}
return err
}
Loading
Loading