Add multi-ID support to update, show, and label commands

Implements GitHub issue #58: Allow multiple issue IDs for batch operations.

Changes:
- update: Now accepts multiple IDs for batch status/priority updates
- show: Displays multiple issues with separators between them
- label add/remove: Apply labels to multiple issues at once
- All commands return arrays in JSON mode for consistency

Commands already supporting multiple IDs:
- close (already implemented)
- reopen (already implemented)

Updated AGENTS.md with correct multi-ID syntax examples.

Amp-Thread-ID: https://ampcode.com/threads/T-518a7593-6e16-4b08-8cf8-741992b5e3b6
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-21 22:18:06 -07:00
parent 16f53c99a2
commit 5aa5940147
3 changed files with 251 additions and 119 deletions

View File

@@ -110,8 +110,9 @@ bd create "Issue title" -t bug -p 1 -l bug,critical --json
# Create multiple issues from markdown file # Create multiple issues from markdown file
bd create -f feature-plan.md --json bd create -f feature-plan.md --json
# Update issue status # Update one or more issues
bd update <id> --status in_progress --json bd update <id> [<id>...] --status in_progress --json
bd update <id> [<id>...] --priority 1 --json
# Link discovered work (old way) # Link discovered work (old way)
bd dep add <discovered-id> <parent-id> --type discovered-from bd dep add <discovered-id> <parent-id> --type discovered-from
@@ -119,23 +120,26 @@ bd dep add <discovered-id> <parent-id> --type discovered-from
# Create and link in one command (new way) # Create and link in one command (new way)
bd create "Issue title" -t bug -p 1 --deps discovered-from:<parent-id> --json bd create "Issue title" -t bug -p 1 --deps discovered-from:<parent-id> --json
# Label management # Label management (supports multiple IDs)
bd label add <id> <label> --json bd label add <id> [<id>...] <label> --json
bd label remove <id> <label> --json bd label remove <id> [<id>...] <label> --json
bd label list <id> --json bd label list <id> --json
bd label list-all --json bd label list-all --json
# Filter issues by label # Filter issues by label
bd list --label bug,critical --json bd list --label bug,critical --json
# Complete work # Complete work (supports multiple IDs)
bd close <id> --reason "Done" --json bd close <id> [<id>...] --reason "Done" --json
# Reopen closed issues (supports multiple IDs)
bd reopen <id> [<id>...] --reason "Reopening" --json
# Show dependency tree # Show dependency tree
bd dep tree <id> bd dep tree <id>
# Get issue details # Get issue details (supports multiple IDs)
bd show <id> --json bd show <id> [<id>...] --json
# Rename issue prefix (e.g., from 'knowledge-work-' to 'kw-') # Rename issue prefix (e.g., from 'knowledge-work-' to 'kw-')
bd rename-prefix kw- --dry-run # Preview changes bd rename-prefix kw- --dry-run # Preview changes

View File

@@ -70,24 +70,102 @@ func executeLabelCommand(issueID, label, operation string, operationFunc func(co
} }
var labelAddCmd = &cobra.Command{ var labelAddCmd = &cobra.Command{
Use: "add [issue-id] [label]", Use: "add [issue-id...] [label]",
Short: "Add a label to an issue", Short: "Add a label to one or more issues",
Args: cobra.ExactArgs(2), Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
executeLabelCommand(args[0], args[1], "added", func(ctx context.Context, issueID, label, actor string) error { // Last arg is the label, everything before is issue IDs
return store.AddLabel(ctx, issueID, label, actor) label := args[len(args)-1]
issueIDs := args[:len(args)-1]
ctx := context.Background()
results := []map[string]interface{}{}
for _, issueID := range issueIDs {
var err error
if daemonClient != nil {
_, err = daemonClient.AddLabel(&rpc.LabelAddArgs{
ID: issueID,
Label: label,
}) })
} else {
err = store.AddLabel(ctx, issueID, label, actor)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error adding label to %s: %v\n", issueID, err)
continue
}
if jsonOutput {
results = append(results, map[string]interface{}{
"status": "added",
"issue_id": issueID,
"label": label,
})
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Added label '%s' to %s\n", green("✓"), label, issueID)
}
}
if len(issueIDs) > 0 && daemonClient == nil {
markDirtyAndScheduleFlush()
}
if jsonOutput && len(results) > 0 {
outputJSON(results)
}
}, },
} }
var labelRemoveCmd = &cobra.Command{ var labelRemoveCmd = &cobra.Command{
Use: "remove [issue-id] [label]", Use: "remove [issue-id...] [label]",
Short: "Remove a label from an issue", Short: "Remove a label from one or more issues",
Args: cobra.ExactArgs(2), Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
executeLabelCommand(args[0], args[1], "removed", func(ctx context.Context, issueID, label, actor string) error { // Last arg is the label, everything before is issue IDs
return store.RemoveLabel(ctx, issueID, label, actor) label := args[len(args)-1]
issueIDs := args[:len(args)-1]
ctx := context.Background()
results := []map[string]interface{}{}
for _, issueID := range issueIDs {
var err error
if daemonClient != nil {
_, err = daemonClient.RemoveLabel(&rpc.LabelRemoveArgs{
ID: issueID,
Label: label,
}) })
} else {
err = store.RemoveLabel(ctx, issueID, label, actor)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error removing label from %s: %v\n", issueID, err)
continue
}
if jsonOutput {
results = append(results, map[string]interface{}{
"status": "removed",
"issue_id": issueID,
"label": label,
})
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Removed label '%s' from %s\n", green("✓"), label, issueID)
}
}
if len(issueIDs) > 0 && daemonClient == nil {
markDirtyAndScheduleFlush()
}
if jsonOutput && len(results) > 0 {
outputJSON(results)
}
}, },
} }

View File

@@ -1679,26 +1679,40 @@ func init() {
} }
var showCmd = &cobra.Command{ var showCmd = &cobra.Command{
Use: "show [id]", Use: "show [id...]",
Short: "Show issue details", Short: "Show issue details",
Args: cobra.ExactArgs(1), Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// If daemon is running, use RPC // If daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
showArgs := &rpc.ShowArgs{ID: args[0]} allDetails := []interface{}{}
for idx, id := range args {
showArgs := &rpc.ShowArgs{ID: id}
resp, err := daemonClient.Show(showArgs) resp, err := daemonClient.Show(showArgs)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err)
os.Exit(1) continue
} }
if jsonOutput { if jsonOutput {
fmt.Println(string(resp.Data)) type IssueDetails struct {
types.Issue
Labels []string `json:"labels,omitempty"`
Dependencies []*types.Issue `json:"dependencies,omitempty"`
Dependents []*types.Issue `json:"dependents,omitempty"`
}
var details IssueDetails
if err := json.Unmarshal(resp.Data, &details); err == nil {
allDetails = append(allDetails, details)
}
} else { } else {
// Check if issue exists (daemon returns null for non-existent issues) // Check if issue exists (daemon returns null for non-existent issues)
if string(resp.Data) == "null" || len(resp.Data) == 0 { if string(resp.Data) == "null" || len(resp.Data) == 0 {
fmt.Fprintf(os.Stderr, "Issue %s not found\n", args[0]) fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
os.Exit(1) continue
}
if idx > 0 {
fmt.Println("\n" + strings.Repeat("─", 60))
} }
// Parse response and use existing formatting code // Parse response and use existing formatting code
@@ -1798,19 +1812,26 @@ var showCmd = &cobra.Command{
fmt.Println() fmt.Println()
} }
}
if jsonOutput && len(allDetails) > 0 {
outputJSON(allDetails)
}
return return
} }
// Direct mode // Direct mode
ctx := context.Background() ctx := context.Background()
issue, err := store.GetIssue(ctx, args[0]) allDetails := []interface{}{}
for idx, id := range args {
issue, err := store.GetIssue(ctx, id)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err)
os.Exit(1) continue
} }
if issue == nil { if issue == nil {
fmt.Fprintf(os.Stderr, "Issue %s not found\n", args[0]) fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
os.Exit(1) continue
} }
if jsonOutput { if jsonOutput {
@@ -1827,8 +1848,12 @@ var showCmd = &cobra.Command{
details.Dependencies, _ = store.GetDependencies(ctx, issue.ID) details.Dependencies, _ = store.GetDependencies(ctx, issue.ID)
details.Dependents, _ = store.GetDependents(ctx, issue.ID) details.Dependents, _ = store.GetDependents(ctx, issue.ID)
details.Comments, _ = store.GetIssueComments(ctx, issue.ID) details.Comments, _ = store.GetIssueComments(ctx, issue.ID)
outputJSON(details) allDetails = append(allDetails, details)
return continue
}
if idx > 0 {
fmt.Println("\n" + strings.Repeat("─", 60))
} }
cyan := color.New(color.FgCyan).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc()
@@ -1930,6 +1955,11 @@ var showCmd = &cobra.Command{
} }
fmt.Println() fmt.Println()
}
if jsonOutput && len(allDetails) > 0 {
outputJSON(allDetails)
}
}, },
} }
@@ -1938,9 +1968,9 @@ func init() {
} }
var updateCmd = &cobra.Command{ var updateCmd = &cobra.Command{
Use: "update [id]", Use: "update [id...]",
Short: "Update an issue", Short: "Update one or more issues",
Args: cobra.ExactArgs(1), Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
updates := make(map[string]interface{}) updates := make(map[string]interface{})
@@ -1984,7 +2014,9 @@ var updateCmd = &cobra.Command{
// If daemon is running, use RPC // If daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
updateArgs := &rpc.UpdateArgs{ID: args[0]} updatedIssues := []*types.Issue{}
for _, id := range args {
updateArgs := &rpc.UpdateArgs{ID: id}
// Map updates to RPC args // Map updates to RPC args
if status, ok := updates["status"].(string); ok { if status, ok := updates["status"].(string); ok {
@@ -2011,36 +2043,54 @@ var updateCmd = &cobra.Command{
resp, err := daemonClient.Update(updateArgs) resp, err := daemonClient.Update(updateArgs)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
os.Exit(1) continue
} }
if jsonOutput { if jsonOutput {
fmt.Println(string(resp.Data)) var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err == nil {
updatedIssues = append(updatedIssues, &issue)
}
} else { } else {
green := color.New(color.FgGreen).SprintFunc() green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Updated issue: %s\n", green("✓"), args[0]) fmt.Printf("%s Updated issue: %s\n", green("✓"), id)
}
}
if jsonOutput && len(updatedIssues) > 0 {
outputJSON(updatedIssues)
} }
return return
} }
// Direct mode // Direct mode
ctx := context.Background() ctx := context.Background()
if err := store.UpdateIssue(ctx, args[0], updates, actor); err != nil { updatedIssues := []*types.Issue{}
fmt.Fprintf(os.Stderr, "Error: %v\n", err) for _, id := range args {
os.Exit(1) if err := store.UpdateIssue(ctx, id, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue
} }
// Schedule auto-flush
markDirtyAndScheduleFlush()
if jsonOutput { if jsonOutput {
// Fetch updated issue and output issue, _ := store.GetIssue(ctx, id)
issue, _ := store.GetIssue(ctx, args[0]) if issue != nil {
outputJSON(issue) updatedIssues = append(updatedIssues, issue)
}
} else { } else {
green := color.New(color.FgGreen).SprintFunc() green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Updated issue: %s\n", green("✓"), args[0]) fmt.Printf("%s Updated issue: %s\n", green("✓"), id)
}
}
// Schedule auto-flush if any issues were updated
if len(args) > 0 {
markDirtyAndScheduleFlush()
}
if jsonOutput && len(updatedIssues) > 0 {
outputJSON(updatedIssues)
} }
}, },
} }