Files
beads/cmd/bd/update.go
onyx a89f47cdf2 feat(gate): add bd gate discover for gh:run await_id auto-discovery (bd-z6kw)
Implements automatic discovery of GitHub workflow run IDs for gates
awaiting CI/CD completion. This enables the Refinery patrol to
auto-populate await_id for gh:run gates that were created without one.

Changes:
- Add `bd gate discover` command that:
  - Finds open gh:run gates without await_id
  - Queries recent GitHub workflow runs via gh CLI
  - Matches runs to gates using heuristics (branch, commit, time)
  - Updates gates with discovered run IDs

- Add `--await-id` flag to `bd update` for manual setting
- Add AwaitID to UpdateArgs in RPC protocol
- Add await_id to allowedUpdateFields in storage layer

Matching heuristics (scored, highest match wins):
- Commit SHA match: +100 points
- Branch match: +50 points
- Time proximity (<5min: +30, <10min: +20, <30min: +10)
- In-progress/queued status: +5 points

Usage:
  bd gate discover           # Auto-discover for all matching gates
  bd gate discover --dry-run # Preview without updating
  bd gate discover --branch main --limit 10

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 16:14:35 -08:00

607 lines
19 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/hooks"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/timeparsing"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
"github.com/steveyegge/beads/internal/validation"
)
var updateCmd = &cobra.Command{
Use: "update [id...]",
GroupID: "issues",
Short: "Update one or more issues",
Long: `Update one or more issues.
If no issue ID is provided, updates the last touched issue (from most recent
create, update, show, or close operation).`,
Args: cobra.MinimumNArgs(0),
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("update")
// If no IDs provided, use last touched issue
if len(args) == 0 {
lastTouched := GetLastTouchedID()
if lastTouched == "" {
FatalErrorRespectJSON("no issue ID provided and no last touched issue")
}
args = []string{lastTouched}
}
updates := make(map[string]interface{})
if cmd.Flags().Changed("status") {
status, _ := cmd.Flags().GetString("status")
updates["status"] = status
// If status is being set to closed, include session if provided
if status == "closed" {
session, _ := cmd.Flags().GetString("session")
if session == "" {
session = os.Getenv("CLAUDE_SESSION_ID")
}
if session != "" {
updates["closed_by_session"] = session
}
}
}
if cmd.Flags().Changed("priority") {
priorityStr, _ := cmd.Flags().GetString("priority")
priority, err := validation.ValidatePriority(priorityStr)
if err != nil {
FatalErrorRespectJSON("%v", err)
}
updates["priority"] = priority
}
if cmd.Flags().Changed("title") {
title, _ := cmd.Flags().GetString("title")
updates["title"] = title
}
if cmd.Flags().Changed("assignee") {
assignee, _ := cmd.Flags().GetString("assignee")
updates["assignee"] = assignee
}
description, descChanged := getDescriptionFlag(cmd)
if descChanged {
updates["description"] = description
}
if cmd.Flags().Changed("design") {
design, _ := cmd.Flags().GetString("design")
updates["design"] = design
}
if cmd.Flags().Changed("notes") {
notes, _ := cmd.Flags().GetString("notes")
updates["notes"] = notes
}
if cmd.Flags().Changed("acceptance") || cmd.Flags().Changed("acceptance-criteria") {
var acceptanceCriteria string
if cmd.Flags().Changed("acceptance") {
acceptanceCriteria, _ = cmd.Flags().GetString("acceptance")
} else {
acceptanceCriteria, _ = cmd.Flags().GetString("acceptance-criteria")
}
updates["acceptance_criteria"] = acceptanceCriteria
}
if cmd.Flags().Changed("external-ref") {
externalRef, _ := cmd.Flags().GetString("external-ref")
updates["external_ref"] = externalRef
}
if cmd.Flags().Changed("estimate") {
estimate, _ := cmd.Flags().GetInt("estimate")
if estimate < 0 {
FatalErrorRespectJSON("estimate must be a non-negative number of minutes")
}
updates["estimated_minutes"] = estimate
}
if cmd.Flags().Changed("type") {
issueType, _ := cmd.Flags().GetString("type")
// Validate issue type
if !types.IssueType(issueType).IsValid() {
FatalErrorRespectJSON("invalid issue type %q. Valid types: bug, feature, task, epic, chore, merge-request, molecule, gate", issueType)
}
updates["issue_type"] = issueType
}
if cmd.Flags().Changed("add-label") {
addLabels, _ := cmd.Flags().GetStringSlice("add-label")
updates["add_labels"] = addLabels
}
if cmd.Flags().Changed("remove-label") {
removeLabels, _ := cmd.Flags().GetStringSlice("remove-label")
updates["remove_labels"] = removeLabels
}
if cmd.Flags().Changed("set-labels") {
setLabels, _ := cmd.Flags().GetStringSlice("set-labels")
updates["set_labels"] = setLabels
}
if cmd.Flags().Changed("parent") {
parent, _ := cmd.Flags().GetString("parent")
updates["parent"] = parent
}
if cmd.Flags().Changed("type") {
issueType, _ := cmd.Flags().GetString("type")
// Validate issue type
if _, err := validation.ParseIssueType(issueType); err != nil {
FatalErrorRespectJSON("%v", err)
}
updates["issue_type"] = issueType
}
// Gate fields (bd-z6kw)
if cmd.Flags().Changed("await-id") {
awaitID, _ := cmd.Flags().GetString("await-id")
updates["await_id"] = awaitID
}
// Time-based scheduling flags (GH#820)
if cmd.Flags().Changed("due") {
dueStr, _ := cmd.Flags().GetString("due")
if dueStr == "" {
// Empty string clears the due date
updates["due_at"] = nil
} else {
t, err := timeparsing.ParseRelativeTime(dueStr, time.Now())
if err != nil {
FatalErrorRespectJSON("invalid --due format %q. Examples: +6h, tomorrow, next monday, 2025-01-15", dueStr)
}
updates["due_at"] = t
}
}
if cmd.Flags().Changed("defer") {
deferStr, _ := cmd.Flags().GetString("defer")
if deferStr == "" {
// Empty string clears the defer_until
updates["defer_until"] = nil
} else {
t, err := timeparsing.ParseRelativeTime(deferStr, time.Now())
if err != nil {
FatalErrorRespectJSON("invalid --defer format %q. Examples: +1h, tomorrow, next monday, 2025-01-15", deferStr)
}
// Warn if defer date is in the past (user probably meant future)
if t.Before(time.Now()) && !jsonOutput {
fmt.Fprintf(os.Stderr, "%s Defer date %q is in the past. Issue will appear in bd ready immediately.\n",
ui.RenderWarn("!"), t.Format("2006-01-02 15:04"))
fmt.Fprintf(os.Stderr, " Did you mean a future date? Use --defer=+1h or --defer=tomorrow\n")
}
updates["defer_until"] = t
}
}
// Get claim flag
claimFlag, _ := cmd.Flags().GetBool("claim")
if len(updates) == 0 && !claimFlag {
fmt.Println("No updates specified")
return
}
ctx := rootCtx
// Resolve partial IDs first, checking for cross-rig routing
var resolvedIDs []string
var routedArgs []string // IDs that need cross-repo routing (bypass daemon)
if daemonClient != nil {
// In daemon mode, resolve via RPC - but check routing first
for _, id := range args {
// Check if this ID needs routing to a different beads directory
if needsRouting(id) {
routedArgs = append(routedArgs, id)
continue
}
resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
FatalErrorRespectJSON("resolving ID %s: %v", id, err)
}
var resolvedID string
if err := json.Unmarshal(resp.Data, &resolvedID); err != nil {
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
}
resolvedIDs = append(resolvedIDs, resolvedID)
}
} else {
var err error
resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args)
if err != nil {
FatalErrorRespectJSON("%v", err)
}
}
// If daemon is running, use RPC
if daemonClient != nil {
updatedIssues := []*types.Issue{}
var firstUpdatedID string // Track first successful update for last-touched
for _, id := range resolvedIDs {
updateArgs := &rpc.UpdateArgs{ID: id}
// Map updates to RPC args
if status, ok := updates["status"].(string); ok {
updateArgs.Status = &status
}
if priority, ok := updates["priority"].(int); ok {
updateArgs.Priority = &priority
}
if title, ok := updates["title"].(string); ok {
updateArgs.Title = &title
}
if assignee, ok := updates["assignee"].(string); ok {
updateArgs.Assignee = &assignee
}
if description, ok := updates["description"].(string); ok {
updateArgs.Description = &description
}
if design, ok := updates["design"].(string); ok {
updateArgs.Design = &design
}
if notes, ok := updates["notes"].(string); ok {
updateArgs.Notes = &notes
}
if acceptanceCriteria, ok := updates["acceptance_criteria"].(string); ok {
updateArgs.AcceptanceCriteria = &acceptanceCriteria
}
if externalRef, ok := updates["external_ref"].(string); ok {
updateArgs.ExternalRef = &externalRef
}
if estimate, ok := updates["estimated_minutes"].(int); ok {
updateArgs.EstimatedMinutes = &estimate
}
if issueType, ok := updates["issue_type"].(string); ok {
updateArgs.IssueType = &issueType
}
if addLabels, ok := updates["add_labels"].([]string); ok {
updateArgs.AddLabels = addLabels
}
if removeLabels, ok := updates["remove_labels"].([]string); ok {
updateArgs.RemoveLabels = removeLabels
}
if setLabels, ok := updates["set_labels"].([]string); ok {
updateArgs.SetLabels = setLabels
}
if issueType, ok := updates["issue_type"].(string); ok {
updateArgs.IssueType = &issueType
}
if parent, ok := updates["parent"].(string); ok {
updateArgs.Parent = &parent
}
// Gate fields (bd-z6kw)
if awaitID, ok := updates["await_id"].(string); ok {
updateArgs.AwaitID = &awaitID
}
// Time-based scheduling (GH#820)
if dueAt, ok := updates["due_at"].(time.Time); ok {
s := dueAt.Format(time.RFC3339)
updateArgs.DueAt = &s
} else if updates["due_at"] == nil && cmd.Flags().Changed("due") {
// Explicit clear
empty := ""
updateArgs.DueAt = &empty
}
if deferUntil, ok := updates["defer_until"].(time.Time); ok {
s := deferUntil.Format(time.RFC3339)
updateArgs.DeferUntil = &s
} else if updates["defer_until"] == nil && cmd.Flags().Changed("defer") {
// Explicit clear
empty := ""
updateArgs.DeferUntil = &empty
}
// Set claim flag for atomic claim operation
updateArgs.Claim = claimFlag
resp, err := daemonClient.Update(updateArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue
}
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err == nil {
// Run update hook
if hookRunner != nil {
hookRunner.Run(hooks.EventUpdate, &issue)
}
if jsonOutput {
updatedIssues = append(updatedIssues, &issue)
}
}
if !jsonOutput {
fmt.Printf("%s Updated issue: %s\n", ui.RenderPass("✓"), id)
}
// Track first successful update for last-touched
if firstUpdatedID == "" {
firstUpdatedID = id
}
}
// Handle routed IDs via direct mode (bypass daemon)
for _, id := range routedArgs {
result, err := resolveAndGetIssueWithRouting(ctx, store, id)
if err != nil {
if result != nil {
result.Close()
}
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
continue
}
if result == nil || result.Issue == nil {
if result != nil {
result.Close()
}
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue
}
issue := result.Issue
issueStore := result.Store
if err := validateIssueUpdatable(id, issue); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
result.Close()
continue
}
// Handle claim operation atomically
if claimFlag {
if issue.Assignee != "" {
fmt.Fprintf(os.Stderr, "Error claiming %s: already claimed by %s\n", id, issue.Assignee)
result.Close()
continue
}
claimUpdates := map[string]interface{}{
"assignee": actor,
"status": "in_progress",
}
if err := issueStore.UpdateIssue(ctx, result.ResolvedID, claimUpdates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error claiming %s: %v\n", id, err)
result.Close()
continue
}
}
// Apply regular field updates if any
regularUpdates := make(map[string]interface{})
for k, v := range updates {
if k != "add_labels" && k != "remove_labels" && k != "set_labels" && k != "parent" {
regularUpdates[k] = v
}
}
if len(regularUpdates) > 0 {
if err := issueStore.UpdateIssue(ctx, result.ResolvedID, regularUpdates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
result.Close()
continue
}
}
// Handle label operations
var setLabels, addLabels, removeLabels []string
if v, ok := updates["set_labels"].([]string); ok {
setLabels = v
}
if v, ok := updates["add_labels"].([]string); ok {
addLabels = v
}
if v, ok := updates["remove_labels"].([]string); ok {
removeLabels = v
}
if len(setLabels) > 0 || len(addLabels) > 0 || len(removeLabels) > 0 {
if err := applyLabelUpdates(ctx, issueStore, result.ResolvedID, actor, setLabels, addLabels, removeLabels); err != nil {
fmt.Fprintf(os.Stderr, "Error updating labels for %s: %v\n", id, err)
result.Close()
continue
}
}
// Run update hook
updatedIssue, _ := issueStore.GetIssue(ctx, result.ResolvedID)
if updatedIssue != nil && hookRunner != nil {
hookRunner.Run(hooks.EventUpdate, updatedIssue)
}
if jsonOutput {
if updatedIssue != nil {
updatedIssues = append(updatedIssues, updatedIssue)
}
} else {
fmt.Printf("%s Updated issue: %s\n", ui.RenderPass("✓"), result.ResolvedID)
}
if firstUpdatedID == "" {
firstUpdatedID = result.ResolvedID
}
result.Close()
}
if jsonOutput && len(updatedIssues) > 0 {
outputJSON(updatedIssues)
}
// Set last touched after all updates complete
if firstUpdatedID != "" {
SetLastTouchedID(firstUpdatedID)
}
return
}
// Direct mode
updatedIssues := []*types.Issue{}
var firstUpdatedID string // Track first successful update for last-touched
for _, id := range resolvedIDs {
// Check if issue is a template: templates are read-only
issue, err := store.GetIssue(ctx, id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting %s: %v\n", id, err)
continue
}
if err := validateIssueUpdatable(id, issue); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
continue
}
// Handle claim operation atomically
if claimFlag {
// Check if already claimed (has non-empty assignee)
if issue.Assignee != "" {
fmt.Fprintf(os.Stderr, "Error claiming %s: already claimed by %s\n", id, issue.Assignee)
continue
}
// Atomically set assignee and status
claimUpdates := map[string]interface{}{
"assignee": actor,
"status": "in_progress",
}
if err := store.UpdateIssue(ctx, id, claimUpdates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error claiming %s: %v\n", id, err)
continue
}
}
// Apply regular field updates if any
regularUpdates := make(map[string]interface{})
for k, v := range updates {
if k != "add_labels" && k != "remove_labels" && k != "set_labels" && k != "parent" {
regularUpdates[k] = v
}
}
if len(regularUpdates) > 0 {
if err := store.UpdateIssue(ctx, id, regularUpdates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue
}
}
// Handle label operations
var setLabels, addLabels, removeLabels []string
if v, ok := updates["set_labels"].([]string); ok {
setLabels = v
}
if v, ok := updates["add_labels"].([]string); ok {
addLabels = v
}
if v, ok := updates["remove_labels"].([]string); ok {
removeLabels = v
}
if len(setLabels) > 0 || len(addLabels) > 0 || len(removeLabels) > 0 {
if err := applyLabelUpdates(ctx, store, id, actor, setLabels, addLabels, removeLabels); err != nil {
fmt.Fprintf(os.Stderr, "Error updating labels for %s: %v\n", id, err)
continue
}
}
// Handle parent reparenting
if newParent, ok := updates["parent"].(string); ok {
// Validate new parent exists (unless empty string to remove parent)
if newParent != "" {
parentIssue, err := store.GetIssue(ctx, newParent)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting parent %s: %v\n", newParent, err)
continue
}
if parentIssue == nil {
fmt.Fprintf(os.Stderr, "Error: parent issue %s not found\n", newParent)
continue
}
}
// Find and remove existing parent-child dependency
deps, err := store.GetDependencyRecords(ctx, id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting dependencies for %s: %v\n", id, err)
continue
}
for _, dep := range deps {
if dep.Type == types.DepParentChild {
if err := store.RemoveDependency(ctx, id, dep.DependsOnID, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error removing old parent dependency: %v\n", err)
}
break
}
}
// Add new parent-child dependency (if not removing parent)
if newParent != "" {
newDep := &types.Dependency{
IssueID: id,
DependsOnID: newParent,
Type: types.DepParentChild,
}
if err := store.AddDependency(ctx, newDep, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error adding parent dependency: %v\n", err)
continue
}
}
}
// Run update hook
updatedIssue, _ := store.GetIssue(ctx, id)
if updatedIssue != nil && hookRunner != nil {
hookRunner.Run(hooks.EventUpdate, updatedIssue)
}
if jsonOutput {
if updatedIssue != nil {
updatedIssues = append(updatedIssues, updatedIssue)
}
} else {
fmt.Printf("%s Updated issue: %s\n", ui.RenderPass("✓"), id)
}
// Track first successful update for last-touched
if firstUpdatedID == "" {
firstUpdatedID = id
}
}
// Set last touched after all updates complete
if firstUpdatedID != "" {
SetLastTouchedID(firstUpdatedID)
}
// Schedule auto-flush if any issues were updated
if len(args) > 0 {
markDirtyAndScheduleFlush()
}
if jsonOutput && len(updatedIssues) > 0 {
outputJSON(updatedIssues)
}
},
}
func init() {
updateCmd.Flags().StringP("status", "s", "", "New status")
registerPriorityFlag(updateCmd, "")
updateCmd.Flags().String("title", "", "New title")
updateCmd.Flags().StringP("type", "t", "", "New type (bug|feature|task|epic|chore|merge-request|molecule|gate)")
registerCommonIssueFlags(updateCmd)
updateCmd.Flags().String("acceptance-criteria", "", "DEPRECATED: use --acceptance")
_ = updateCmd.Flags().MarkHidden("acceptance-criteria") // Only fails if flag missing (caught in tests)
updateCmd.Flags().IntP("estimate", "e", 0, "Time estimate in minutes (e.g., 60 for 1 hour)")
updateCmd.Flags().StringSlice("add-label", nil, "Add labels (repeatable)")
updateCmd.Flags().StringSlice("remove-label", nil, "Remove labels (repeatable)")
updateCmd.Flags().StringSlice("set-labels", nil, "Set labels, replacing all existing (repeatable)")
updateCmd.Flags().String("parent", "", "New parent issue ID (reparents the issue, use empty string to remove parent)")
updateCmd.Flags().Bool("claim", false, "Atomically claim the issue (sets assignee to you, status to in_progress; fails if already claimed)")
updateCmd.Flags().String("session", "", "Claude Code session ID for status=closed (or set CLAUDE_SESSION_ID env var)")
// Time-based scheduling flags (GH#820)
// Examples:
// --due=+6h Due in 6 hours
// --due=tomorrow Due tomorrow
// --due="next monday" Due next Monday
// --due=2025-01-15 Due on specific date
// --due="" Clear due date
// --defer=+1h Hidden from bd ready for 1 hour
// --defer="" Clear defer (show in bd ready immediately)
updateCmd.Flags().String("due", "", "Due date/time (empty to clear). Formats: +6h, +1d, +2w, tomorrow, next monday, 2025-01-15")
updateCmd.Flags().String("defer", "", "Defer until date (empty to clear). Issue hidden from bd ready until then")
// Gate fields (bd-z6kw)
updateCmd.Flags().String("await-id", "", "Set gate await_id (e.g., GitHub run ID for gh:run gates)")
rootCmd.AddCommand(updateCmd)
}