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:
271
internal/hooks/config_hooks_test.go
Normal file
271
internal/hooks/config_hooks_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user