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:
@@ -1057,6 +1057,8 @@ var closeCmd = &cobra.Command{
|
|||||||
if hookRunner != nil {
|
if hookRunner != nil {
|
||||||
hookRunner.Run(hooks.EventClose, &issue)
|
hookRunner.Run(hooks.EventClose, &issue)
|
||||||
}
|
}
|
||||||
|
// Run config-based close hooks (bd-g4b4)
|
||||||
|
hooks.RunConfigCloseHooks(ctx, &issue)
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
closedIssues = append(closedIssues, &issue)
|
closedIssues = append(closedIssues, &issue)
|
||||||
}
|
}
|
||||||
@@ -1109,8 +1111,12 @@ var closeCmd = &cobra.Command{
|
|||||||
|
|
||||||
// Run close hook (bd-kwro.8)
|
// Run close hook (bd-kwro.8)
|
||||||
closedIssue, _ := store.GetIssue(ctx, id)
|
closedIssue, _ := store.GetIssue(ctx, id)
|
||||||
if closedIssue != nil && hookRunner != nil {
|
if closedIssue != nil {
|
||||||
hookRunner.Run(hooks.EventClose, closedIssue)
|
if hookRunner != nil {
|
||||||
|
hookRunner.Run(hooks.EventClose, closedIssue)
|
||||||
|
}
|
||||||
|
// Run config-based close hooks (bd-g4b4)
|
||||||
|
hooks.RunConfigCloseHooks(ctx, closedIssue)
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
|
|||||||
@@ -104,6 +104,73 @@ external_projects:
|
|||||||
gastown: /path/to/gastown
|
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?
|
### Why Two Systems?
|
||||||
|
|
||||||
**Tool settings (Viper)** are user preferences:
|
**Tool settings (Viper)** are user preferences:
|
||||||
|
|||||||
@@ -306,6 +306,43 @@ func ResolveExternalProjectPath(projectName string) string {
|
|||||||
return path
|
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.
|
// GetIdentity resolves the user's identity for messaging.
|
||||||
// Priority chain:
|
// Priority chain:
|
||||||
// 1. flagValue (if non-empty, from --identity flag)
|
// 1. flagValue (if non-empty, from --identity flag)
|
||||||
|
|||||||
66
internal/hooks/config_hooks.go
Normal file
66
internal/hooks/config_hooks.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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