From c46b1a0bbb463e17d95069fe4af7bf3bf265b1ee Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 23 Dec 2025 13:38:09 -0800 Subject: [PATCH] Add config-based close hooks (bd-g4b4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements on_close hooks in .beads/config.yaml for automation and notifications. Hooks receive issue data via environment variables (BEAD_ID, BEAD_TITLE, BEAD_TYPE, BEAD_PRIORITY, BEAD_CLOSE_REASON) and run via sh -c. Changes: - internal/config: Add HookEntry type and GetCloseHooks() - internal/hooks: Add RunConfigCloseHooks() for executing config hooks - cmd/bd: Call RunConfigCloseHooks after successful close - docs/CONFIG.md: Document hooks configuration with examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/show.go | 10 +- docs/CONFIG.md | 67 +++++++ internal/config/config.go | 37 ++++ internal/hooks/config_hooks.go | 66 +++++++ internal/hooks/config_hooks_test.go | 271 ++++++++++++++++++++++++++++ 5 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 internal/hooks/config_hooks.go create mode 100644 internal/hooks/config_hooks_test.go diff --git a/cmd/bd/show.go b/cmd/bd/show.go index 0ea31106..1f457414 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -1057,6 +1057,8 @@ var closeCmd = &cobra.Command{ if hookRunner != nil { hookRunner.Run(hooks.EventClose, &issue) } + // Run config-based close hooks (bd-g4b4) + hooks.RunConfigCloseHooks(ctx, &issue) if jsonOutput { closedIssues = append(closedIssues, &issue) } @@ -1109,8 +1111,12 @@ var closeCmd = &cobra.Command{ // Run close hook (bd-kwro.8) closedIssue, _ := store.GetIssue(ctx, id) - if closedIssue != nil && hookRunner != nil { - hookRunner.Run(hooks.EventClose, closedIssue) + if closedIssue != nil { + if hookRunner != nil { + hookRunner.Run(hooks.EventClose, closedIssue) + } + // Run config-based close hooks (bd-g4b4) + hooks.RunConfigCloseHooks(ctx, closedIssue) } if jsonOutput { diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 292143ee..ad958142 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -104,6 +104,73 @@ external_projects: gastown: /path/to/gastown ``` +### Hooks Configuration + +bd supports config-based hooks for automation and notifications. Currently, close hooks are implemented. + +#### Close Hooks + +Close hooks run after an issue is successfully closed via `bd close`. They execute synchronously but failures are logged as warnings and don't block the close operation. + +**Configuration:** + +```yaml +# .beads/config.yaml +hooks: + on_close: + - name: show-next + command: bd ready --limit 1 + - name: context-check + command: echo "Issue $BEAD_ID closed. Check context if nearing limit." + - command: notify-team.sh # name is optional +``` + +**Environment Variables:** + +Hook commands receive issue data via environment variables: + +| Variable | Description | +|----------|-------------| +| `BEAD_ID` | Issue ID (e.g., `bd-abc1`) | +| `BEAD_TITLE` | Issue title | +| `BEAD_TYPE` | Issue type (`task`, `bug`, `feature`, etc.) | +| `BEAD_PRIORITY` | Priority (0-4) | +| `BEAD_CLOSE_REASON` | Close reason if provided | + +**Example Use Cases:** + +1. **Show next work item:** + ```yaml + hooks: + on_close: + - name: next-task + command: bd ready --limit 1 + ``` + +2. **Context check reminder:** + ```yaml + hooks: + on_close: + - name: context-check + command: | + echo "Issue $BEAD_ID ($BEAD_TITLE) closed." + echo "Priority was P$BEAD_PRIORITY. Reason: $BEAD_CLOSE_REASON" + ``` + +3. **Integration with external tools:** + ```yaml + hooks: + on_close: + - name: slack-notify + command: curl -X POST "$SLACK_WEBHOOK" -d "{\"text\":\"Closed: $BEAD_ID - $BEAD_TITLE\"}" + ``` + +**Notes:** +- Hooks have a 10-second timeout +- Hook failures log warnings but don't fail the close operation +- Commands run via `sh -c`, so shell features like pipes and redirects work +- Both script-based hooks (`.beads/hooks/on_close`) and config-based hooks run + ### Why Two Systems? **Tool settings (Viper)** are user preferences: diff --git a/internal/config/config.go b/internal/config/config.go index 74484a29..46b9a48f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -306,6 +306,43 @@ func ResolveExternalProjectPath(projectName string) string { return path } +// HookEntry represents a single config-based hook +type HookEntry struct { + Command string `yaml:"command" mapstructure:"command"` // Shell command to run + Name string `yaml:"name" mapstructure:"name"` // Optional display name +} + +// GetCloseHooks returns the on_close hooks from config +func GetCloseHooks() []HookEntry { + if v == nil { + return nil + } + var hooks []HookEntry + raw := v.Get("hooks.on_close") + if raw == nil { + return nil + } + + // Handle slice of maps (from YAML parsing) + if rawSlice, ok := raw.([]interface{}); ok { + for _, item := range rawSlice { + if m, ok := item.(map[string]interface{}); ok { + entry := HookEntry{} + if cmd, ok := m["command"].(string); ok { + entry.Command = cmd + } + if name, ok := m["name"].(string); ok { + entry.Name = name + } + if entry.Command != "" { + hooks = append(hooks, entry) + } + } + } + } + return hooks +} + // GetIdentity resolves the user's identity for messaging. // Priority chain: // 1. flagValue (if non-empty, from --identity flag) diff --git a/internal/hooks/config_hooks.go b/internal/hooks/config_hooks.go new file mode 100644 index 00000000..a54ce8b8 --- /dev/null +++ b/internal/hooks/config_hooks.go @@ -0,0 +1,66 @@ +// Package hooks provides a hook system for extensibility. +// This file implements config-based hooks defined in .beads/config.yaml. + +package hooks + +import ( + "context" + "fmt" + "os" + "os/exec" + "strconv" + "time" + + "github.com/steveyegge/beads/internal/config" + "github.com/steveyegge/beads/internal/types" +) + +// RunConfigCloseHooks executes all on_close hooks from config.yaml. +// Hook commands receive issue data via environment variables: +// - BEAD_ID: Issue ID (e.g., bd-abc1) +// - BEAD_TITLE: Issue title +// - BEAD_TYPE: Issue type (task, bug, feature, etc.) +// - BEAD_PRIORITY: Priority (0-4) +// - BEAD_CLOSE_REASON: Close reason if provided +// +// Hooks run synchronously but failures are logged as warnings and don't +// block the close operation. +func RunConfigCloseHooks(ctx context.Context, issue *types.Issue) { + hooks := config.GetCloseHooks() + if len(hooks) == 0 { + return + } + + // Build environment variables for hooks + env := append(os.Environ(), + "BEAD_ID="+issue.ID, + "BEAD_TITLE="+issue.Title, + "BEAD_TYPE="+string(issue.IssueType), + "BEAD_PRIORITY="+strconv.Itoa(issue.Priority), + "BEAD_CLOSE_REASON="+issue.CloseReason, + ) + + timeout := 10 * time.Second + + for _, hook := range hooks { + hookCtx, cancel := context.WithTimeout(ctx, timeout) + + // #nosec G204 -- command comes from user's config file + cmd := exec.CommandContext(hookCtx, "sh", "-c", hook.Command) + cmd.Env = env + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err := cmd.Run() + cancel() + + if err != nil { + // Log warning but don't fail the close + name := hook.Name + if name == "" { + name = hook.Command + } + fmt.Fprintf(os.Stderr, "Warning: close hook %q failed: %v\n", name, err) + } + } +} diff --git a/internal/hooks/config_hooks_test.go b/internal/hooks/config_hooks_test.go new file mode 100644 index 00000000..48def26a --- /dev/null +++ b/internal/hooks/config_hooks_test.go @@ -0,0 +1,271 @@ +package hooks + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/steveyegge/beads/internal/config" + "github.com/steveyegge/beads/internal/types" +) + +func TestRunConfigCloseHooks_NoHooks(t *testing.T) { + // Create a temp dir without any config + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create .beads dir: %v", err) + } + + // Change to the temp dir and initialize config + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + + // Re-initialize config + if err := config.Initialize(); err != nil { + t.Fatalf("Failed to initialize config: %v", err) + } + + issue := &types.Issue{ID: "bd-test", Title: "Test Issue"} + ctx := context.Background() + + // Should not panic with no hooks + RunConfigCloseHooks(ctx, issue) +} + +func TestRunConfigCloseHooks_ExecutesCommand(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create .beads dir: %v", err) + } + + outputFile := filepath.Join(tmpDir, "hook_output.txt") + + // Create config.yaml with a close hook + configContent := `hooks: + on_close: + - name: test-hook + command: echo "$BEAD_ID $BEAD_TITLE" > ` + outputFile + ` +` + configPath := filepath.Join(beadsDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + // Change to the temp dir and initialize config + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + + // Re-initialize config + if err := config.Initialize(); err != nil { + t.Fatalf("Failed to initialize config: %v", err) + } + + issue := &types.Issue{ + ID: "bd-abc1", + Title: "Test Issue", + IssueType: types.TypeBug, + Priority: 1, + CloseReason: "Fixed", + } + ctx := context.Background() + + RunConfigCloseHooks(ctx, issue) + + // Wait for hook to complete + time.Sleep(100 * time.Millisecond) + + // Verify output + output, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + expected := "bd-abc1 Test Issue" + if !strings.Contains(string(output), expected) { + t.Errorf("Hook output = %q, want to contain %q", string(output), expected) + } +} + +func TestRunConfigCloseHooks_EnvVars(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create .beads dir: %v", err) + } + + outputFile := filepath.Join(tmpDir, "env_output.txt") + + // Create config.yaml with a close hook that outputs all env vars + configContent := `hooks: + on_close: + - name: env-check + command: echo "ID=$BEAD_ID TYPE=$BEAD_TYPE PRIORITY=$BEAD_PRIORITY REASON=$BEAD_CLOSE_REASON" > ` + outputFile + ` +` + configPath := filepath.Join(beadsDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + // Change to the temp dir and initialize config + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + + // Re-initialize config + if err := config.Initialize(); err != nil { + t.Fatalf("Failed to initialize config: %v", err) + } + + issue := &types.Issue{ + ID: "bd-xyz9", + Title: "Bug Fix", + IssueType: types.TypeFeature, + Priority: 2, + CloseReason: "Completed", + } + ctx := context.Background() + + RunConfigCloseHooks(ctx, issue) + + // Wait for hook to complete + time.Sleep(100 * time.Millisecond) + + // Verify output contains all env vars + output, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + outputStr := string(output) + checks := []string{ + "ID=bd-xyz9", + "TYPE=feature", + "PRIORITY=2", + "REASON=Completed", + } + + for _, check := range checks { + if !strings.Contains(outputStr, check) { + t.Errorf("Hook output = %q, want to contain %q", outputStr, check) + } + } +} + +func TestRunConfigCloseHooks_HookFailure(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create .beads dir: %v", err) + } + + successFile := filepath.Join(tmpDir, "success.txt") + + // Create config.yaml with a failing hook followed by a succeeding one + configContent := `hooks: + on_close: + - name: failing-hook + command: exit 1 + - name: success-hook + command: echo "success" > ` + successFile + ` +` + configPath := filepath.Join(beadsDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + // Change to the temp dir and initialize config + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + + // Re-initialize config + if err := config.Initialize(); err != nil { + t.Fatalf("Failed to initialize config: %v", err) + } + + issue := &types.Issue{ID: "bd-test", Title: "Test"} + ctx := context.Background() + + // Should not panic even with failing hook + RunConfigCloseHooks(ctx, issue) + + // Wait for hooks to complete + time.Sleep(100 * time.Millisecond) + + // Verify second hook still ran + output, err := os.ReadFile(successFile) + if err != nil { + t.Fatalf("Second hook should have run despite first failing: %v", err) + } + + if !strings.Contains(string(output), "success") { + t.Error("Second hook did not produce expected output") + } +} + +func TestGetCloseHooks(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create .beads dir: %v", err) + } + + // Create config.yaml with multiple hooks + configContent := `hooks: + on_close: + - name: first-hook + command: echo first + - name: second-hook + command: echo second + - command: echo unnamed +` + configPath := filepath.Join(beadsDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + // Change to the temp dir and initialize config + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + + // Re-initialize config + if err := config.Initialize(); err != nil { + t.Fatalf("Failed to initialize config: %v", err) + } + + hooks := config.GetCloseHooks() + + if len(hooks) != 3 { + t.Fatalf("Expected 3 hooks, got %d", len(hooks)) + } + + if hooks[0].Name != "first-hook" || hooks[0].Command != "echo first" { + t.Errorf("First hook = %+v, want name=first-hook, command=echo first", hooks[0]) + } + + if hooks[1].Name != "second-hook" || hooks[1].Command != "echo second" { + t.Errorf("Second hook = %+v, want name=second-hook, command=echo second", hooks[1]) + } + + if hooks[2].Name != "" || hooks[2].Command != "echo unnamed" { + t.Errorf("Third hook = %+v, want name='', command=echo unnamed", hooks[2]) + } +}