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
bd create -f feature-plan.md --json
# Update issue status
bd update <id> --status in_progress --json
# Update one or more issues
bd update <id> [<id>...] --status in_progress --json
bd update <id> [<id>...] --priority 1 --json
# Link discovered work (old way)
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)
bd create "Issue title" -t bug -p 1 --deps discovered-from:<parent-id> --json
# Label management
bd label add <id> <label> --json
bd label remove <id> <label> --json
# Label management (supports multiple IDs)
bd label add <id> [<id>...] <label> --json
bd label remove <id> [<id>...] <label> --json
bd label list <id> --json
bd label list-all --json
# Filter issues by label
bd list --label bug,critical --json
# Complete work
bd close <id> --reason "Done" --json
# Complete work (supports multiple IDs)
bd close <id> [<id>...] --reason "Done" --json
# Reopen closed issues (supports multiple IDs)
bd reopen <id> [<id>...] --reason "Reopening" --json
# Show dependency tree
bd dep tree <id>
# Get issue details
bd show <id> --json
# Get issue details (supports multiple IDs)
bd show <id> [<id>...] --json
# Rename issue prefix (e.g., from 'knowledge-work-' to 'kw-')
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{
Use: "add [issue-id] [label]",
Short: "Add a label to an issue",
Args: cobra.ExactArgs(2),
Use: "add [issue-id...] [label]",
Short: "Add a label to one or more issues",
Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
executeLabelCommand(args[0], args[1], "added", func(ctx context.Context, issueID, label, actor string) error {
return store.AddLabel(ctx, issueID, label, actor)
})
// Last arg is the label, everything before is issue IDs
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{
Use: "remove [issue-id] [label]",
Short: "Remove a label from an issue",
Args: cobra.ExactArgs(2),
Use: "remove [issue-id...] [label]",
Short: "Remove a label from one or more issues",
Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
executeLabelCommand(args[0], args[1], "removed", func(ctx context.Context, issueID, label, actor string) error {
return store.RemoveLabel(ctx, issueID, label, actor)
})
// Last arg is the label, everything before is issue IDs
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,28 +1679,42 @@ func init() {
}
var showCmd = &cobra.Command{
Use: "show [id]",
Use: "show [id...]",
Short: "Show issue details",
Args: cobra.ExactArgs(1),
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
// If daemon is running, use RPC
if daemonClient != nil {
showArgs := &rpc.ShowArgs{ID: args[0]}
resp, err := daemonClient.Show(showArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
fmt.Println(string(resp.Data))
} else {
// Check if issue exists (daemon returns null for non-existent issues)
if string(resp.Data) == "null" || len(resp.Data) == 0 {
fmt.Fprintf(os.Stderr, "Issue %s not found\n", args[0])
os.Exit(1)
allDetails := []interface{}{}
for idx, id := range args {
showArgs := &rpc.ShowArgs{ID: id}
resp, err := daemonClient.Show(showArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err)
continue
}
if jsonOutput {
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 {
// Check if issue exists (daemon returns null for non-existent issues)
if string(resp.Data) == "null" || len(resp.Data) == 0 {
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue
}
if idx > 0 {
fmt.Println("\n" + strings.Repeat("─", 60))
}
// Parse response and use existing formatting code
type IssueDetails struct {
types.Issue
@@ -1796,40 +1810,51 @@ var showCmd = &cobra.Command{
}
}
fmt.Println()
fmt.Println()
}
}
if jsonOutput && len(allDetails) > 0 {
outputJSON(allDetails)
}
return
}
// Direct mode
ctx := context.Background()
issue, err := store.GetIssue(ctx, args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if issue == nil {
fmt.Fprintf(os.Stderr, "Issue %s not found\n", args[0])
os.Exit(1)
}
if jsonOutput {
// Include labels, dependencies, and comments in JSON output
type IssueDetails struct {
*types.Issue
Labels []string `json:"labels,omitempty"`
Dependencies []*types.Issue `json:"dependencies,omitempty"`
Dependents []*types.Issue `json:"dependents,omitempty"`
Comments []*types.Comment `json:"comments,omitempty"`
allDetails := []interface{}{}
for idx, id := range args {
issue, err := store.GetIssue(ctx, id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err)
continue
}
if issue == nil {
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue
}
if jsonOutput {
// Include labels, dependencies, and comments in JSON output
type IssueDetails struct {
*types.Issue
Labels []string `json:"labels,omitempty"`
Dependencies []*types.Issue `json:"dependencies,omitempty"`
Dependents []*types.Issue `json:"dependents,omitempty"`
Comments []*types.Comment `json:"comments,omitempty"`
}
details := &IssueDetails{Issue: issue}
details.Labels, _ = store.GetLabels(ctx, issue.ID)
details.Dependencies, _ = store.GetDependencies(ctx, issue.ID)
details.Dependents, _ = store.GetDependents(ctx, issue.ID)
details.Comments, _ = store.GetIssueComments(ctx, issue.ID)
allDetails = append(allDetails, details)
continue
}
if idx > 0 {
fmt.Println("\n" + strings.Repeat("─", 60))
}
details := &IssueDetails{Issue: issue}
details.Labels, _ = store.GetLabels(ctx, issue.ID)
details.Dependencies, _ = store.GetDependencies(ctx, issue.ID)
details.Dependents, _ = store.GetDependents(ctx, issue.ID)
details.Comments, _ = store.GetIssueComments(ctx, issue.ID)
outputJSON(details)
return
}
cyan := color.New(color.FgCyan).SprintFunc()
@@ -1920,16 +1945,21 @@ var showCmd = &cobra.Command{
}
}
// Show comments
comments, _ := store.GetIssueComments(ctx, issue.ID)
if len(comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(comments))
for _, comment := range comments {
fmt.Printf(" [%s at %s]\n %s\n\n", comment.Author, comment.CreatedAt.Format("2006-01-02 15:04"), comment.Text)
// Show comments
comments, _ := store.GetIssueComments(ctx, issue.ID)
if len(comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(comments))
for _, comment := range comments {
fmt.Printf(" [%s at %s]\n %s\n\n", comment.Author, comment.CreatedAt.Format("2006-01-02 15:04"), comment.Text)
}
}
fmt.Println()
}
fmt.Println()
if jsonOutput && len(allDetails) > 0 {
outputJSON(allDetails)
}
},
}
@@ -1938,9 +1968,9 @@ func init() {
}
var updateCmd = &cobra.Command{
Use: "update [id]",
Short: "Update an issue",
Args: cobra.ExactArgs(1),
Use: "update [id...]",
Short: "Update one or more issues",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
updates := make(map[string]interface{})
@@ -1984,63 +2014,83 @@ var updateCmd = &cobra.Command{
// If daemon is running, use RPC
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
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 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
// 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 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
}
resp, err := daemonClient.Update(updateArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue
}
if jsonOutput {
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err == nil {
updatedIssues = append(updatedIssues, &issue)
}
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Updated issue: %s\n", green("✓"), id)
}
}
resp, err := daemonClient.Update(updateArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
fmt.Println(string(resp.Data))
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Updated issue: %s\n", green("✓"), args[0])
if jsonOutput && len(updatedIssues) > 0 {
outputJSON(updatedIssues)
}
return
}
// Direct mode
ctx := context.Background()
if err := store.UpdateIssue(ctx, args[0], updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
updatedIssues := []*types.Issue{}
for _, id := range args {
if err := store.UpdateIssue(ctx, id, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue
}
if jsonOutput {
issue, _ := store.GetIssue(ctx, id)
if issue != nil {
updatedIssues = append(updatedIssues, issue)
}
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Updated issue: %s\n", green("✓"), id)
}
}
// Schedule auto-flush
markDirtyAndScheduleFlush()
// Schedule auto-flush if any issues were updated
if len(args) > 0 {
markDirtyAndScheduleFlush()
}
if jsonOutput {
// Fetch updated issue and output
issue, _ := store.GetIssue(ctx, args[0])
outputJSON(issue)
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Updated issue: %s\n", green("✓"), args[0])
if jsonOutput && len(updatedIssues) > 0 {
outputJSON(updatedIssues)
}
},
}