Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ab3aca3
Add review hooks system for running commands on review events (#180)
wesm Jan 31, 2026
9ede30a
Fix hooks: slice aliasing, unsubscribe leak, shell injection
wesm Jan 31, 2026
0a23b0f
Add test for quoted placeholders, document auto-escaping in docs
wesm Jan 31, 2026
bd0210b
Add tests for global+repo hook resolution and isolation
wesm Jan 31, 2026
3479631
Fix test hygiene: marker path, WriteFile errors, negative assertions
wesm Jan 31, 2026
b1c0593
Fix hooks for Windows: use cmd /C, platform-aware escaping and tests
wesm Jan 31, 2026
a410645
Fix hook tests to use platform-aware quoting expectations
wesm Jan 31, 2026
d1364e0
Fix tautological injection test to assert safety properties
wesm Jan 31, 2026
5486120
Show daemon version in status, add fix hint to beads hook
wesm Jan 31, 2026
9b09843
Log hook activity, add fix hint to beads hook message
wesm Jan 31, 2026
a0631f2
Fix verdict not shown for branch range reviews
wesm Jan 31, 2026
21eeb19
Address review findings: log writer restoration, range verdict test
wesm Jan 31, 2026
3bb1631
Harden hooks: test guards, payload assertions, Windows escaping
wesm Jan 31, 2026
1a63d33
Fix data race in hook log tests
wesm Jan 31, 2026
52ec209
Fix Windows hook tests: use copy nul, add /D to disable AutoRun
wesm Jan 31, 2026
39e5b6b
Fix Windows hook tests: use PowerShell for reliable path handling
wesm Jan 31, 2026
af8046d
Skip shell integration tests on Windows
wesm Jan 31, 2026
e82b517
Use PowerShell instead of cmd.exe for hooks on Windows
wesm Jan 31, 2026
ecae764
Fix Windows hook tests: forward slashes, path resolution, noopCmd
wesm Jan 31, 2026
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
11 changes: 7 additions & 4 deletions cmd/roborev/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1092,12 +1092,15 @@ func statusCmd() *cobra.Command {
json.NewDecoder(healthResp.Body).Decode(&health)
}

// Display daemon info with uptime
// Display daemon info with uptime and version
daemonLine := "Daemon: running"
if health.Uptime != "" {
fmt.Printf("Daemon: running (uptime: %s)\n", health.Uptime)
} else {
fmt.Println("Daemon: running")
daemonLine += fmt.Sprintf(" (uptime: %s)", health.Uptime)
}
if status.Version != "" {
daemonLine += fmt.Sprintf(" [%s]", status.Version)
}
fmt.Println(daemonLine)
fmt.Printf("Workers: %d/%d active\n", status.ActiveWorkers, status.MaxWorkers)
fmt.Printf("Jobs: %d queued, %d running, %d completed, %d failed\n",
status.QueuedJobs, status.RunningJobs, status.CompletedJobs, status.FailedJobs)
Expand Down
13 changes: 13 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import (
"github.com/roborev-dev/roborev/internal/git"
)

// 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
}

// Config holds the daemon configuration
type Config struct {
ServerAddr string `toml:"server_addr"`
Expand Down Expand Up @@ -56,6 +63,9 @@ type Config struct {
// API keys (optional - agents use subscription auth by default)
AnthropicAPIKey string `toml:"anthropic_api_key"`

// Hooks configuration
Hooks []HookConfig `toml:"hooks"`

// Sync configuration for PostgreSQL
Sync SyncConfig `toml:"sync"`

Expand Down Expand Up @@ -176,6 +186,9 @@ type RepoConfig struct {
FixModelStandard string `toml:"fix_model_standard"`
FixModelThorough string `toml:"fix_model_thorough"`

// Hooks configuration (per-repo)
Hooks []HookConfig `toml:"hooks"`

// Analysis settings
MaxPromptSize int `toml:"max_prompt_size"` // Max prompt size in bytes before falling back to paths (overrides global default)
}
Expand Down
213 changes: 213 additions & 0 deletions internal/daemon/hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package daemon

import (
"fmt"
"log"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"

"github.com/roborev-dev/roborev/internal/config"
)

// HookRunner listens for broadcaster events and runs configured hooks.
type HookRunner struct {
cfgGetter ConfigGetter
broadcaster Broadcaster
subID int
mu sync.RWMutex
stopCh chan struct{}
}

// NewHookRunner creates a new HookRunner that subscribes to events from the broadcaster.
func NewHookRunner(cfgGetter ConfigGetter, broadcaster Broadcaster) *HookRunner {
subID, eventCh := broadcaster.Subscribe("")

hr := &HookRunner{
cfgGetter: cfgGetter,
broadcaster: broadcaster,
subID: subID,
stopCh: make(chan struct{}),
}

go hr.listen(eventCh)

return hr
}

// listen processes events from the broadcaster and fires matching hooks.
func (hr *HookRunner) listen(eventCh <-chan Event) {
for {
select {
case <-hr.stopCh:
return
case event, ok := <-eventCh:
if !ok {
return
}
hr.handleEvent(event)
}
}
}

// Stop shuts down the hook runner and unsubscribes from the broadcaster.
func (hr *HookRunner) Stop() {
close(hr.stopCh)
hr.broadcaster.Unsubscribe(hr.subID)
}

// handleEvent checks all configured hooks against the event and fires matches.
func (hr *HookRunner) handleEvent(event Event) {
// Only handle review events
if !strings.HasPrefix(event.Type, "review.") {
return
}

cfg := hr.cfgGetter.Config()
if cfg == nil {
return
}

// Collect hooks: copy global slice to avoid aliasing, then append repo-specific
hooks := append([]config.HookConfig{}, cfg.Hooks...)

if event.Repo != "" {
if repoCfg, err := config.LoadRepoConfig(event.Repo); err == nil && repoCfg != nil {
hooks = append(hooks, repoCfg.Hooks...)
}
}

fired := 0
for _, hook := range hooks {
if !matchEvent(hook.Event, event.Type) {
continue
}

cmd := resolveCommand(hook, event)
if cmd == "" {
continue
}

fired++
// Run async so hooks don't block workers
go runHook(cmd, event.Repo)
}

if fired > 0 {
log.Printf("Hooks: fired %d hook(s) for %s (job %d)", fired, event.Type, event.JobID)
}
}

// matchEvent checks if an event type matches a hook's event pattern.
// Supports exact match and "review.*" wildcard.
func matchEvent(pattern, eventType string) bool {
if pattern == eventType {
return true
}
// Support wildcard like "review.*"
if strings.HasSuffix(pattern, ".*") {
prefix := strings.TrimSuffix(pattern, ".*")
return strings.HasPrefix(eventType, prefix+".")
}
return false
}

// resolveCommand builds the shell command for a hook, handling built-in types
// and template variable interpolation.
func resolveCommand(hook config.HookConfig, event Event) string {
if hook.Type == "beads" {
return beadsCommand(event)
}
return interpolate(hook.Command, event)
}

// beadsCommand generates a bd create command for the beads built-in hook.
func beadsCommand(event Event) string {
repoName := event.RepoName
if repoName == "" {
repoName = filepath.Base(event.Repo)
}

shortSHA := event.SHA
if len(shortSHA) > 8 {
shortSHA = shortSHA[:8]
}

switch event.Type {
case "review.failed":
title := fmt.Sprintf("Review failed for %s (%s): run roborev show %d", repoName, shortSHA, event.JobID)
return fmt.Sprintf("bd create %q -p 1", title)
case "review.completed":
if event.Verdict == "F" {
title := fmt.Sprintf("Review findings for %s (%s): roborev show %d / one-shot fix with roborev fix %d", repoName, shortSHA, event.JobID, event.JobID)
return fmt.Sprintf("bd create %q -p 2", title)
}
return "" // No issue for passing reviews
default:
return ""
}
}

// interpolate replaces {var} template variables in a command string.
// Values are shell-escaped to prevent injection via event fields.
func interpolate(cmd string, event Event) string {
if cmd == "" {
return ""
}

r := strings.NewReplacer(
"{job_id}", fmt.Sprintf("%d", event.JobID),
"{repo}", shellEscape(event.Repo),
"{repo_name}", shellEscape(event.RepoName),
"{sha}", shellEscape(event.SHA),
"{agent}", shellEscape(event.Agent),
"{verdict}", shellEscape(event.Verdict),
"{error}", shellEscape(event.Error),
)
return r.Replace(cmd)
}

// shellEscape quotes a value for safe interpolation into a shell command.
// On Unix, wraps in single quotes with embedded single quotes escaped.
// On Windows, wraps in double quotes with embedded double quotes escaped.
func shellEscape(s string) string {
if runtime.GOOS == "windows" {
// PowerShell single-quoted strings: only escape is '' for literal '.
if s == "" {
return "''"
}
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
if s == "" {
return "''"
}
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}

// runHook executes a shell command in the given working directory.
// Errors are logged but never propagated.
func runHook(command, workDir string) {
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
// Use PowerShell for reliable path handling and command execution.
// -NoProfile avoids loading user profiles that could slow or alter execution.
// -Command takes the rest as a PowerShell script string.
cmd = exec.Command("powershell", "-NoProfile", "-Command", command)
} else {
cmd = exec.Command("sh", "-c", command)
}
if workDir != "" {
cmd.Dir = workDir
}

output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("Hook error (cmd=%q dir=%q): %v\n%s", command, workDir, err, output)
return
}
if len(output) > 0 {
log.Printf("Hook output (cmd=%q): %s", command, output)
}
}
Loading
Loading