Add config-based close hooks (bd-g4b4)

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-23 13:38:09 -08:00
parent bc0973bfa2
commit c46b1a0bbb
5 changed files with 449 additions and 2 deletions

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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])
}
}