feat: Implement Step.Gate evaluation (Phase 1: Human Gates)

This implements Phase 1 of the Step.Gate feature (bd-7zka.2):

- bd cook now creates gate issues for steps with gate fields
- Gate issues have type=gate and block the gated step via dependency
- bd list filters out gate issues by default (use --include-gates to show)
- New bd gate command with list and resolve subcommands

Gate types supported in Phase 1:
- human: Manual closure via bd close or bd gate resolve

Implementation details:
- createGateIssue() in cook.go creates gate issues with proper metadata
- collectSteps() creates gate dependencies when processing gated steps
- IssueFilter.ExcludeTypes added to storage layer for type-based filtering
- Gate command provides dedicated UX for gate management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jasper
2026-01-01 16:24:32 -08:00
committed by Steve Yegge
parent 09d38de6df
commit b73085962c
9 changed files with 297 additions and 968 deletions

View File

@@ -476,6 +476,47 @@ func cookFormulaToSubgraph(f *formula.Formula, protoID string) (*TemplateSubgrap
}, nil
}
// createGateIssue creates a gate issue for a step with a Gate field.
// Gate issues have type=gate and block the step they guard.
// Returns the gate issue and its ID.
func createGateIssue(step *formula.Step, parentID string) *types.Issue {
if step.Gate == nil {
return nil
}
// Generate gate issue ID: {parentID}.gate-{step.ID}
gateID := fmt.Sprintf("%s.gate-%s", parentID, step.ID)
// Build title from gate type and ID
title := fmt.Sprintf("Gate: %s", step.Gate.Type)
if step.Gate.ID != "" {
title = fmt.Sprintf("Gate: %s %s", step.Gate.Type, step.Gate.ID)
}
// Parse timeout if specified
var timeout time.Duration
if step.Gate.Timeout != "" {
if parsed, err := time.ParseDuration(step.Gate.Timeout); err == nil {
timeout = parsed
}
}
return &types.Issue{
ID: gateID,
Title: title,
Description: fmt.Sprintf("Async gate for step %s", step.ID),
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeGate,
AwaitType: step.Gate.Type,
AwaitID: step.Gate.ID,
Timeout: timeout,
IsTemplate: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
// processStepToIssue converts a formula.Step to a types.Issue.
// The issue includes all fields including Labels populated from step.Labels and waits_for.
// This is the shared core logic used by both DB-persisted and in-memory cooking.
@@ -562,6 +603,41 @@ func collectSteps(steps []*formula.Step, parentID string,
Type: types.DepParentChild,
})
// Create gate issue if step has a Gate (bd-7zka.2)
if step.Gate != nil {
gateIssue := createGateIssue(step, parentID)
*issues = append(*issues, gateIssue)
// Add gate to mapping (use gate-{step.ID} as key)
gateKey := fmt.Sprintf("gate-%s", step.ID)
idMapping[gateKey] = gateIssue.ID
if issueMap != nil {
issueMap[gateIssue.ID] = gateIssue
}
// Handle gate labels if needed
if labelHandler != nil && len(gateIssue.Labels) > 0 {
for _, label := range gateIssue.Labels {
labelHandler(gateIssue.ID, label)
}
gateIssue.Labels = nil
}
// Gate is a child of the parent (same level as the step)
*deps = append(*deps, &types.Dependency{
IssueID: gateIssue.ID,
DependsOnID: parentID,
Type: types.DepParentChild,
})
// Step depends on gate (gate blocks the step)
*deps = append(*deps, &types.Dependency{
IssueID: issue.ID,
DependsOnID: gateIssue.ID,
Type: types.DepBlocks,
})
}
// Recursively collect children
if len(step.Children) > 0 {
collectSteps(step.Children, issue.ID, idMapping, issueMap, issues, deps, labelHandler)

File diff suppressed because it is too large Load Diff

View File

@@ -415,6 +415,9 @@ var listCmd = &cobra.Command{
// Template filtering
includeTemplates, _ := cmd.Flags().GetBool("include-templates")
// Gate filtering (bd-7zka.2)
includeGates, _ := cmd.Flags().GetBool("include-gates")
// Parent filtering
parentID, _ := cmd.Flags().GetString("parent")
@@ -620,6 +623,12 @@ var listCmd = &cobra.Command{
filter.IsTemplate = &isTemplate
}
// Gate filtering: exclude gate issues by default (bd-7zka.2)
// Use --include-gates or --type gate to show gate issues
if !includeGates && issueType != "gate" {
filter.ExcludeTypes = append(filter.ExcludeTypes, types.TypeGate)
}
// Parent filtering: filter children by parent issue
if parentID != "" {
filter.ParentID = &parentID
@@ -721,6 +730,13 @@ var listCmd = &cobra.Command{
}
}
// Type exclusion (bd-7zka.2)
if len(filter.ExcludeTypes) > 0 {
for _, t := range filter.ExcludeTypes {
listArgs.ExcludeTypes = append(listArgs.ExcludeTypes, string(t))
}
}
resp, err := daemonClient.List(listArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -961,6 +977,9 @@ func init() {
// Template filtering: exclude templates by default
listCmd.Flags().Bool("include-templates", false, "Include template molecules in output")
// Gate filtering: exclude gate issues by default (bd-7zka.2)
listCmd.Flags().Bool("include-gates", false, "Include gate issues in output (normally hidden)")
// Parent filtering: filter children by parent issue
listCmd.Flags().String("parent", "", "Filter by parent issue ID (shows children of specified issue)")

View File

@@ -183,7 +183,8 @@ type Step struct {
Children []*Step `json:"children,omitempty"`
// Gate defines an async wait condition for this step.
// TODO(bd-7zka): Not yet implemented in bd cook. Will integrate with bd-udsi gates.
// When set, bd cook creates a gate issue that blocks this step.
// Close the gate issue (bd close bd-xxx.gate-stepid) to unblock.
Gate *Gate `json:"gate,omitempty"`
// Loop defines iteration for this step.
@@ -206,8 +207,9 @@ type Step struct {
SourceLocation string `json:"-"` // Internal only, not serialized to JSON
}
// Gate defines an async wait condition (integrates with bd-udsi).
// TODO(bd-7zka): Not yet implemented in bd cook. Schema defined for future use.
// Gate defines an async wait condition for formula steps.
// When a step has a Gate, bd cook creates a gate issue that blocks the step.
// The gate must be closed (manually or via watchers) to unblock the step.
type Gate struct {
// Type is the condition type: gh:run, gh:pr, timer, human, mail.
Type string `json:"type"`

View File

@@ -233,6 +233,9 @@ type ListArgs struct {
// Status exclusion (for default non-closed behavior, GH#788)
ExcludeStatus []string `json:"exclude_status,omitempty"`
// Type exclusion (for hiding internal types like gates, bd-7zka.2)
ExcludeTypes []string `json:"exclude_types,omitempty"`
}
// CountArgs represents arguments for the count operation

View File

@@ -1117,6 +1117,13 @@ func (s *Server) handleList(req *Request) Response {
}
}
// Type exclusion (for hiding internal types like gates, bd-7zka.2)
if len(listArgs.ExcludeTypes) > 0 {
for _, t := range listArgs.ExcludeTypes {
filter.ExcludeTypes = append(filter.ExcludeTypes, types.IssueType(t))
}
}
// Guard against excessive ID lists to avoid SQLite parameter limits
const maxIDs = 1000
if len(filter.IDs) > maxIDs {

View File

@@ -1719,6 +1719,16 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
whereClauses = append(whereClauses, fmt.Sprintf("status NOT IN (%s)", strings.Join(placeholders, ",")))
}
// Type exclusion (for hiding internal types like gates, bd-7zka.2)
if len(filter.ExcludeTypes) > 0 {
placeholders := make([]string, len(filter.ExcludeTypes))
for i, t := range filter.ExcludeTypes {
placeholders[i] = "?"
args = append(args, string(t))
}
whereClauses = append(whereClauses, fmt.Sprintf("issue_type NOT IN (%s)", strings.Join(placeholders, ",")))
}
if filter.Priority != nil {
whereClauses = append(whereClauses, "priority = ?")
args = append(args, *filter.Priority)

View File

@@ -1081,6 +1081,16 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter
whereClauses = append(whereClauses, fmt.Sprintf("status NOT IN (%s)", strings.Join(placeholders, ",")))
}
// Type exclusion (for hiding internal types like gates, bd-7zka.2)
if len(filter.ExcludeTypes) > 0 {
placeholders := make([]string, len(filter.ExcludeTypes))
for i, t := range filter.ExcludeTypes {
placeholders[i] = "?"
args = append(args, string(t))
}
whereClauses = append(whereClauses, fmt.Sprintf("issue_type NOT IN (%s)", strings.Join(placeholders, ",")))
}
if filter.Priority != nil {
whereClauses = append(whereClauses, "priority = ?")
args = append(args, *filter.Priority)

View File

@@ -791,6 +791,9 @@ type IssueFilter struct {
// Status exclusion (for default non-closed behavior)
ExcludeStatus []Status // Exclude issues with these statuses
// Type exclusion (for hiding internal types like gates)
ExcludeTypes []IssueType // Exclude issues with these types
}
// SortPolicy determines how ready work is ordered