From 702f686fc0ed871025474f9e49b2816f88f2af65 Mon Sep 17 00:00:00 2001 From: aleiby Date: Sat, 24 Jan 2026 17:11:25 -0800 Subject: [PATCH] feat(update): add --append-notes flag (#1304) * feat(update): add --append-notes flag (bd-b5qu) Add --append-notes flag that appends to existing notes with a newline separator instead of overwriting. This prevents data loss in workflows where multiple steps need to add info to notes (e.g., tackle workflows). - Errors if both --notes and --append-notes specified - Handles both daemon and direct mode paths - Combines existing notes + newline + new content Co-Authored-By: Claude Opus 4.5 * test(update): add tests for --append-notes flag --------- Co-authored-by: Claude Opus 4.5 --- cmd/bd/cli_fast_test.go | 58 +++++++++++++++++++++++++++++++++++++++++ cmd/bd/flags.go | 1 + cmd/bd/update.go | 45 ++++++++++++++++++++++++++++++-- 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/cmd/bd/cli_fast_test.go b/cmd/bd/cli_fast_test.go index 560ff771..75632de2 100644 --- a/cmd/bd/cli_fast_test.go +++ b/cmd/bd/cli_fast_test.go @@ -400,6 +400,64 @@ func TestCLI_UpdateEphemeralMutualExclusion(t *testing.T) { } } +func TestCLI_UpdateAppendNotes(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow CLI test in short mode") + } + tmpDir := setupCLITestDB(t) + out := runBDInProcess(t, tmpDir, "create", "Issue for append-notes test", "-p", "2", "--notes", "Original notes", "--json") + + var issue map[string]interface{} + json.Unmarshal([]byte(out), &issue) + id := issue["id"].(string) + + // Test appending notes + runBDInProcess(t, tmpDir, "update", id, "--append-notes", "Appended content") + + out = runBDInProcess(t, tmpDir, "show", id, "--json") + var updated []map[string]interface{} + json.Unmarshal([]byte(out), &updated) + notes := updated[0]["notes"].(string) + if notes != "Original notes\nAppended content" { + t.Errorf("Expected 'Original notes\\nAppended content', got: %q", notes) + } + + // Test appending to empty notes + out = runBDInProcess(t, tmpDir, "create", "Issue with empty notes", "-p", "2", "--json") + json.Unmarshal([]byte(out), &issue) + id2 := issue["id"].(string) + + runBDInProcess(t, tmpDir, "update", id2, "--append-notes", "First note") + + out = runBDInProcess(t, tmpDir, "show", id2, "--json") + json.Unmarshal([]byte(out), &updated) + notes = updated[0]["notes"].(string) + if notes != "First note" { + t.Errorf("Expected 'First note', got: %q", notes) + } +} + +func TestCLI_UpdateAppendNotesMutualExclusion(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow CLI test in short mode") + } + tmpDir := setupCLITestDB(t) + out := runBDInProcess(t, tmpDir, "create", "Issue for notes mutual exclusion", "-p", "2", "--json") + + var issue map[string]interface{} + json.Unmarshal([]byte(out), &issue) + id := issue["id"].(string) + + // Both --notes and --append-notes should error + _, stderr, err := runBDInProcessAllowError(t, tmpDir, "update", id, "--notes", "New notes", "--append-notes", "Appended") + if err == nil { + t.Errorf("Expected error when both --notes and --append-notes specified, got none") + } + if !strings.Contains(stderr, "cannot specify both --notes and --append-notes") { + t.Errorf("Expected mutual exclusion error message, got: %v", stderr) + } +} + func TestCLI_Close(t *testing.T) { if testing.Short() { t.Skip("skipping slow CLI test in short mode") diff --git a/cmd/bd/flags.go b/cmd/bd/flags.go index 40fc894e..d008f6d7 100644 --- a/cmd/bd/flags.go +++ b/cmd/bd/flags.go @@ -22,6 +22,7 @@ func registerCommonIssueFlags(cmd *cobra.Command) { cmd.Flags().String("design", "", "Design notes") cmd.Flags().String("acceptance", "", "Acceptance criteria") cmd.Flags().String("notes", "", "Additional notes") + cmd.Flags().String("append-notes", "", "Append to existing notes (with newline separator)") cmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')") } diff --git a/cmd/bd/update.go b/cmd/bd/update.go index 53606559..61fbe6ad 100644 --- a/cmd/bd/update.go +++ b/cmd/bd/update.go @@ -78,10 +78,17 @@ create, update, show, or close operation).`, design, _ := cmd.Flags().GetString("design") updates["design"] = design } + if cmd.Flags().Changed("notes") && cmd.Flags().Changed("append-notes") { + FatalErrorRespectJSON("cannot specify both --notes and --append-notes") + } if cmd.Flags().Changed("notes") { notes, _ := cmd.Flags().GetString("notes") updates["notes"] = notes } + if cmd.Flags().Changed("append-notes") { + appendNotes, _ := cmd.Flags().GetString("append-notes") + updates["append_notes"] = appendNotes + } if cmd.Flags().Changed("acceptance") || cmd.Flags().Changed("acceptance-criteria") { var acceptanceCriteria string if cmd.Flags().Changed("acceptance") { @@ -243,6 +250,22 @@ create, update, show, or close operation).`, if notes, ok := updates["notes"].(string); ok { updateArgs.Notes = ¬es } + if appendNotes, ok := updates["append_notes"].(string); ok { + // Fetch existing issue to get current notes + showArgs := &rpc.ShowArgs{ID: id} + resp, err := daemonClient.Show(showArgs) + if err == nil { + var existingIssue types.Issue + if err := json.Unmarshal(resp.Data, &existingIssue); err == nil { + combined := existingIssue.Notes + if combined != "" { + combined += "\n" + } + combined += appendNotes + updateArgs.Notes = &combined + } + } + } if acceptanceCriteria, ok := updates["acceptance_criteria"].(string); ok { updateArgs.AcceptanceCriteria = &acceptanceCriteria } @@ -372,10 +395,19 @@ create, update, show, or close operation).`, // 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" { + if k != "add_labels" && k != "remove_labels" && k != "set_labels" && k != "parent" && k != "append_notes" { regularUpdates[k] = v } } + // Handle append_notes: combine existing notes with new content + if appendNotes, ok := updates["append_notes"].(string); ok { + combined := issue.Notes + if combined != "" { + combined += "\n" + } + combined += appendNotes + regularUpdates["notes"] = combined + } 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) @@ -486,10 +518,19 @@ create, update, show, or close operation).`, // 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" { + if k != "add_labels" && k != "remove_labels" && k != "set_labels" && k != "parent" && k != "append_notes" { regularUpdates[k] = v } } + // Handle append_notes: combine existing notes with new content + if appendNotes, ok := updates["append_notes"].(string); ok { + combined := issue.Notes + if combined != "" { + combined += "\n" + } + combined += appendNotes + regularUpdates["notes"] = combined + } 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)