feat: Add label operations to bd update command
Implements bd-au0.2, completing all P0 tasks in the command standardization epic. Changes: - Add --add-label, --remove-label, --set-labels flags to bd update - Support multiple labels via repeatable flags - Implement in both daemon and direct modes - Add comprehensive tests for all label operations The bd update command now supports: bd update <id> --add-label <label> # Add one or more labels bd update <id> --remove-label <label> # Remove one or more labels bd update <id> --set-labels <labels> # Replace all labels 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -248,6 +248,71 @@ func TestCLI_Update(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCLI_UpdateLabels(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping slow CLI test in short mode")
|
||||||
|
}
|
||||||
|
// Note: Not using t.Parallel() because inProcessMutex serializes execution anyway
|
||||||
|
tmpDir := setupCLITestDB(t)
|
||||||
|
out := runBDInProcess(t, tmpDir, "create", "Issue for label testing", "-p", "2", "--json")
|
||||||
|
|
||||||
|
var issue map[string]interface{}
|
||||||
|
json.Unmarshal([]byte(out), &issue)
|
||||||
|
id := issue["id"].(string)
|
||||||
|
|
||||||
|
// Test adding labels
|
||||||
|
runBDInProcess(t, tmpDir, "update", id, "--add-label", "feature", "--add-label", "backend")
|
||||||
|
|
||||||
|
out = runBDInProcess(t, tmpDir, "show", id, "--json")
|
||||||
|
var updated []map[string]interface{}
|
||||||
|
json.Unmarshal([]byte(out), &updated)
|
||||||
|
labels := updated[0]["labels"].([]interface{})
|
||||||
|
if len(labels) != 2 {
|
||||||
|
t.Errorf("Expected 2 labels after add, got: %d", len(labels))
|
||||||
|
}
|
||||||
|
hasBackend, hasFeature := false, false
|
||||||
|
for _, l := range labels {
|
||||||
|
if l.(string) == "backend" {
|
||||||
|
hasBackend = true
|
||||||
|
}
|
||||||
|
if l.(string) == "feature" {
|
||||||
|
hasFeature = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasBackend || !hasFeature {
|
||||||
|
t.Errorf("Expected labels 'backend' and 'feature', got: %v", labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test removing a label
|
||||||
|
runBDInProcess(t, tmpDir, "update", id, "--remove-label", "backend")
|
||||||
|
|
||||||
|
out = runBDInProcess(t, tmpDir, "show", id, "--json")
|
||||||
|
json.Unmarshal([]byte(out), &updated)
|
||||||
|
labels = updated[0]["labels"].([]interface{})
|
||||||
|
if len(labels) != 1 {
|
||||||
|
t.Errorf("Expected 1 label after remove, got: %d", len(labels))
|
||||||
|
}
|
||||||
|
if labels[0].(string) != "feature" {
|
||||||
|
t.Errorf("Expected label 'feature', got: %v", labels[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test setting labels (replaces all)
|
||||||
|
runBDInProcess(t, tmpDir, "update", id, "--set-labels", "api,database,critical")
|
||||||
|
|
||||||
|
out = runBDInProcess(t, tmpDir, "show", id, "--json")
|
||||||
|
json.Unmarshal([]byte(out), &updated)
|
||||||
|
labels = updated[0]["labels"].([]interface{})
|
||||||
|
if len(labels) != 3 {
|
||||||
|
t.Errorf("Expected 3 labels after set, got: %d", len(labels))
|
||||||
|
}
|
||||||
|
expectedLabels := map[string]bool{"api": true, "database": true, "critical": true}
|
||||||
|
for _, l := range labels {
|
||||||
|
if !expectedLabels[l.(string)] {
|
||||||
|
t.Errorf("Unexpected label: %v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCLI_Close(t *testing.T) {
|
func TestCLI_Close(t *testing.T) {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
t.Skip("skipping slow CLI test in short mode")
|
t.Skip("skipping slow CLI test in short mode")
|
||||||
|
|||||||
@@ -393,6 +393,18 @@ var updateCmd = &cobra.Command{
|
|||||||
externalRef, _ := cmd.Flags().GetString("external-ref")
|
externalRef, _ := cmd.Flags().GetString("external-ref")
|
||||||
updates["external_ref"] = externalRef
|
updates["external_ref"] = externalRef
|
||||||
}
|
}
|
||||||
|
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 len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
fmt.Println("No updates specified")
|
fmt.Println("No updates specified")
|
||||||
@@ -461,6 +473,15 @@ var updateCmd = &cobra.Command{
|
|||||||
if externalRef, ok := updates["external_ref"].(string); ok { // NEW: Map external_ref
|
if externalRef, ok := updates["external_ref"].(string); ok { // NEW: Map external_ref
|
||||||
updateArgs.ExternalRef = &externalRef
|
updateArgs.ExternalRef = &externalRef
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := daemonClient.Update(updateArgs)
|
resp, err := daemonClient.Update(updateArgs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -488,10 +509,64 @@ var updateCmd = &cobra.Command{
|
|||||||
// Direct mode
|
// Direct mode
|
||||||
updatedIssues := []*types.Issue{}
|
updatedIssues := []*types.Issue{}
|
||||||
for _, id := range resolvedIDs {
|
for _, id := range resolvedIDs {
|
||||||
if err := store.UpdateIssue(ctx, id, updates, actor); err != nil {
|
// 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" {
|
||||||
|
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)
|
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle label operations
|
||||||
|
// Set labels (replaces all existing labels)
|
||||||
|
if setLabels, ok := updates["set_labels"].([]string); ok && len(setLabels) > 0 {
|
||||||
|
// Get current labels
|
||||||
|
currentLabels, err := store.GetLabels(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting labels for %s: %v\n", id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Remove all current labels
|
||||||
|
for _, label := range currentLabels {
|
||||||
|
if err := store.RemoveLabel(ctx, id, label, actor); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error removing label %s from %s: %v\n", label, id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add new labels
|
||||||
|
for _, label := range setLabels {
|
||||||
|
if err := store.AddLabel(ctx, id, label, actor); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error setting label %s on %s: %v\n", label, id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add labels
|
||||||
|
if addLabels, ok := updates["add_labels"].([]string); ok {
|
||||||
|
for _, label := range addLabels {
|
||||||
|
if err := store.AddLabel(ctx, id, label, actor); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error adding label %s to %s: %v\n", label, id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove labels
|
||||||
|
if removeLabels, ok := updates["remove_labels"].([]string); ok {
|
||||||
|
for _, label := range removeLabels {
|
||||||
|
if err := store.RemoveLabel(ctx, id, label, actor); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error removing label %s from %s: %v\n", label, id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
issue, _ := store.GetIssue(ctx, id)
|
issue, _ := store.GetIssue(ctx, id)
|
||||||
@@ -822,6 +897,9 @@ func init() {
|
|||||||
updateCmd.Flags().String("notes", "", "Additional notes")
|
updateCmd.Flags().String("notes", "", "Additional notes")
|
||||||
updateCmd.Flags().String("acceptance-criteria", "", "DEPRECATED: use --acceptance")
|
updateCmd.Flags().String("acceptance-criteria", "", "DEPRECATED: use --acceptance")
|
||||||
_ = updateCmd.Flags().MarkHidden("acceptance-criteria")
|
_ = updateCmd.Flags().MarkHidden("acceptance-criteria")
|
||||||
|
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().Bool("json", false, "Output JSON format")
|
updateCmd.Flags().Bool("json", false, "Output JSON format")
|
||||||
rootCmd.AddCommand(updateCmd)
|
rootCmd.AddCommand(updateCmd)
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ type UpdateArgs struct {
|
|||||||
Notes *string `json:"notes,omitempty"`
|
Notes *string `json:"notes,omitempty"`
|
||||||
Assignee *string `json:"assignee,omitempty"`
|
Assignee *string `json:"assignee,omitempty"`
|
||||||
ExternalRef *string `json:"external_ref,omitempty"` // Link to external issue trackers
|
ExternalRef *string `json:"external_ref,omitempty"` // Link to external issue trackers
|
||||||
|
AddLabels []string `json:"add_labels,omitempty"`
|
||||||
|
RemoveLabels []string `json:"remove_labels,omitempty"`
|
||||||
|
SetLabels []string `json:"set_labels,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseArgs represents arguments for the close operation
|
// CloseArgs represents arguments for the close operation
|
||||||
|
|||||||
@@ -279,19 +279,73 @@ func (s *Server) handleUpdate(req *Request) Response {
|
|||||||
|
|
||||||
ctx := s.reqCtx(req)
|
ctx := s.reqCtx(req)
|
||||||
updates := updatesFromArgs(updateArgs)
|
updates := updatesFromArgs(updateArgs)
|
||||||
if len(updates) == 0 {
|
actor := s.reqActor(req)
|
||||||
return Response{Success: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.UpdateIssue(ctx, updateArgs.ID, updates, s.reqActor(req)); err != nil {
|
// Apply regular field updates if any
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := store.UpdateIssue(ctx, updateArgs.ID, updates, actor); err != nil {
|
||||||
return Response{
|
return Response{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: fmt.Sprintf("failed to update issue: %v", err),
|
Error: fmt.Sprintf("failed to update issue: %v", err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Emit mutation event for event-driven daemon
|
// Handle label operations
|
||||||
|
// Set labels (replaces all existing labels)
|
||||||
|
if len(updateArgs.SetLabels) > 0 {
|
||||||
|
// Get current labels
|
||||||
|
currentLabels, err := store.GetLabels(ctx, updateArgs.ID)
|
||||||
|
if err != nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to get current labels: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove all current labels
|
||||||
|
for _, label := range currentLabels {
|
||||||
|
if err := store.RemoveLabel(ctx, updateArgs.ID, label, actor); err != nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to remove label %s: %v", label, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add new labels
|
||||||
|
for _, label := range updateArgs.SetLabels {
|
||||||
|
if err := store.AddLabel(ctx, updateArgs.ID, label, actor); err != nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to set label %s: %v", label, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add labels
|
||||||
|
for _, label := range updateArgs.AddLabels {
|
||||||
|
if err := store.AddLabel(ctx, updateArgs.ID, label, actor); err != nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to add label %s: %v", label, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove labels
|
||||||
|
for _, label := range updateArgs.RemoveLabels {
|
||||||
|
if err := store.RemoveLabel(ctx, updateArgs.ID, label, actor); err != nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to remove label %s: %v", label, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit mutation event for event-driven daemon (only if any updates or label operations were performed)
|
||||||
|
if len(updates) > 0 || len(updateArgs.SetLabels) > 0 || len(updateArgs.AddLabels) > 0 || len(updateArgs.RemoveLabels) > 0 {
|
||||||
s.emitMutation(MutationUpdate, updateArgs.ID)
|
s.emitMutation(MutationUpdate, updateArgs.ID)
|
||||||
|
}
|
||||||
|
|
||||||
issue, err := store.GetIssue(ctx, updateArgs.ID)
|
issue, err := store.GetIssue(ctx, updateArgs.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user