Add bd gate approve command for human gates (gt-twjr5.4)

Human gates now have proper workflow:
- Create with: bd gate create --await human:approve-deploy
- Approve with: bd gate approve <gate-id> [--comment "reason"]
- bd gate eval now reports human gates as "awaiting approval"
- Rejects non-human gates with clear error message

Also added mail gate placeholder in eval (awaiting mail: ...).

🤖 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-25 23:35:35 -08:00
parent c366d538a1
commit a0e4267aa0
+150 -3
View File
@@ -445,6 +445,129 @@ var gateCloseCmd = &cobra.Command{
}, },
} }
var gateApproveCmd = &cobra.Command{
Use: "approve <gate-id>",
Short: "Approve a human gate",
Long: `Approve a human gate, closing it and notifying waiters.
Human gates (created with --await human:<prompt>) require explicit approval
to close. This is the command that provides that approval.
Example:
bd gate create --await human:approve-deploy --notify gastown/witness
# ... later, when ready to approve ...
bd gate approve <gate-id>
bd gate approve <gate-id> --comment "Reviewed and approved by Steve"`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("gate approve")
ctx := rootCtx
comment, _ := cmd.Flags().GetString("comment")
var closedGate *types.Issue
var gateID string
// Try daemon first, fall back to direct store access
if daemonClient != nil {
// First get the gate to verify it's a human gate
showResp, err := daemonClient.GateShow(&rpc.GateShowArgs{ID: args[0]})
if err != nil {
FatalError("gate approve: %v", err)
}
var gate types.Issue
if err := json.Unmarshal(showResp.Data, &gate); err != nil {
FatalError("failed to parse gate: %v", err)
}
if gate.AwaitType != "human" {
fmt.Fprintf(os.Stderr, "Error: %s is not a human gate (type: %s:%s)\n", args[0], gate.AwaitType, gate.AwaitID)
os.Exit(1)
}
if gate.Status == types.StatusClosed {
fmt.Fprintf(os.Stderr, "Error: gate %s is already closed\n", args[0])
os.Exit(1)
}
// Close with approval reason
reason := fmt.Sprintf("Human approval granted: %s", gate.AwaitID)
if comment != "" {
reason = fmt.Sprintf("Human approval granted: %s (%s)", gate.AwaitID, comment)
}
resp, err := daemonClient.GateClose(&rpc.GateCloseArgs{
ID: args[0],
Reason: reason,
})
if err != nil {
FatalError("gate approve: %v", err)
}
if err := json.Unmarshal(resp.Data, &closedGate); err != nil {
FatalError("failed to parse gate: %v", err)
}
gateID = closedGate.ID
} else if store != nil {
var err error
gateID, err = utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Get gate and verify it's a human gate
gate, err := store.GetIssue(ctx, gateID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if gate == nil {
fmt.Fprintf(os.Stderr, "Error: gate %s not found\n", gateID)
os.Exit(1)
}
if gate.IssueType != types.TypeGate {
fmt.Fprintf(os.Stderr, "Error: %s is not a gate (type: %s)\n", gateID, gate.IssueType)
os.Exit(1)
}
if gate.AwaitType != "human" {
fmt.Fprintf(os.Stderr, "Error: %s is not a human gate (type: %s:%s)\n", gateID, gate.AwaitType, gate.AwaitID)
os.Exit(1)
}
if gate.Status == types.StatusClosed {
fmt.Fprintf(os.Stderr, "Error: gate %s is already closed\n", gateID)
os.Exit(1)
}
// Close with approval reason
reason := fmt.Sprintf("Human approval granted: %s", gate.AwaitID)
if comment != "" {
reason = fmt.Sprintf("Human approval granted: %s (%s)", gate.AwaitID, comment)
}
if err := store.CloseIssue(ctx, gateID, reason, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error closing gate: %v\n", err)
os.Exit(1)
}
markDirtyAndScheduleFlush()
closedGate, _ = store.GetIssue(ctx, gateID)
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
os.Exit(1)
}
if jsonOutput {
outputJSON(closedGate)
return
}
fmt.Printf("%s Approved gate: %s\n", ui.RenderPass("✓"), gateID)
if closedGate != nil && closedGate.CloseReason != "" {
fmt.Printf(" %s\n", closedGate.CloseReason)
}
if closedGate != nil && len(closedGate.Waiters) > 0 {
fmt.Printf(" Waiters notified: %s\n", strings.Join(closedGate.Waiters, ", "))
}
},
}
var gateWaitCmd = &cobra.Command{ var gateWaitCmd = &cobra.Command{
Use: "wait <gate-id>", Use: "wait <gate-id>",
Short: "Add a waiter to an existing gate", Short: "Add a waiter to an existing gate",
@@ -627,6 +750,8 @@ This command is idempotent and safe to run repeatedly.`,
var closed []string var closed []string
var skipped []string var skipped []string
var awaitingHuman []string
var awaitingMail []string
now := time.Now() now := time.Now()
for _, gate := range gates { for _, gate := range gates {
@@ -640,6 +765,14 @@ This command is idempotent and safe to run repeatedly.`,
shouldClose, reason = evalGHRunGate(gate) shouldClose, reason = evalGHRunGate(gate)
case "gh:pr": case "gh:pr":
shouldClose, reason = evalGHPRGate(gate) shouldClose, reason = evalGHPRGate(gate)
case "human":
// Human gates require explicit approval via 'bd gate approve'
awaitingHuman = append(awaitingHuman, gate.ID)
continue
case "mail":
// Mail gates will be evaluated when mail gate support is added
awaitingMail = append(awaitingMail, gate.ID)
continue
default: default:
// Unsupported gate type - skip // Unsupported gate type - skip
skipped = append(skipped, gate.ID) skipped = append(skipped, gate.ID)
@@ -679,9 +812,11 @@ This command is idempotent and safe to run repeatedly.`,
if jsonOutput { if jsonOutput {
outputJSON(map[string]interface{}{ outputJSON(map[string]interface{}{
"evaluated": len(gates), "evaluated": len(gates),
"closed": closed, "closed": closed,
"skipped": skipped, "awaiting_human": awaitingHuman,
"awaiting_mail": awaitingMail,
"skipped": skipped,
}) })
return return
} }
@@ -698,6 +833,13 @@ This command is idempotent and safe to run repeatedly.`,
fmt.Printf(" %s\n", id) fmt.Printf(" %s\n", id)
} }
} }
if len(awaitingHuman) > 0 {
fmt.Printf("Awaiting human approval: %s\n", strings.Join(awaitingHuman, ", "))
fmt.Printf(" Use 'bd gate approve <id>' to approve\n")
}
if len(awaitingMail) > 0 {
fmt.Printf("Awaiting mail: %s\n", strings.Join(awaitingMail, ", "))
}
if len(skipped) > 0 { if len(skipped) > 0 {
fmt.Printf("Skipped %d unsupported gate(s): %s\n", len(skipped), strings.Join(skipped, ", ")) fmt.Printf("Skipped %d unsupported gate(s): %s\n", len(skipped), strings.Join(skipped, ", "))
} }
@@ -824,6 +966,10 @@ func init() {
gateCloseCmd.Flags().StringP("reason", "r", "", "Reason for closing") gateCloseCmd.Flags().StringP("reason", "r", "", "Reason for closing")
gateCloseCmd.Flags().Bool("json", false, "Output JSON format") gateCloseCmd.Flags().Bool("json", false, "Output JSON format")
// Gate approve flags
gateApproveCmd.Flags().String("comment", "", "Optional approval comment")
gateApproveCmd.Flags().Bool("json", false, "Output JSON format")
// Gate wait flags // Gate wait flags
gateWaitCmd.Flags().StringSlice("notify", nil, "Mail addresses to add as waiters (repeatable, required)") gateWaitCmd.Flags().StringSlice("notify", nil, "Mail addresses to add as waiters (repeatable, required)")
gateWaitCmd.Flags().Bool("json", false, "Output JSON format") gateWaitCmd.Flags().Bool("json", false, "Output JSON format")
@@ -833,6 +979,7 @@ func init() {
gateCmd.AddCommand(gateShowCmd) gateCmd.AddCommand(gateShowCmd)
gateCmd.AddCommand(gateListCmd) gateCmd.AddCommand(gateListCmd)
gateCmd.AddCommand(gateCloseCmd) gateCmd.AddCommand(gateCloseCmd)
gateCmd.AddCommand(gateApproveCmd)
gateCmd.AddCommand(gateWaitCmd) gateCmd.AddCommand(gateWaitCmd)
gateCmd.AddCommand(gateEvalCmd) gateCmd.AddCommand(gateEvalCmd)