From 4f4f46f74570b2495aa0c75de6bc8ebc1fac35a6 Mon Sep 17 00:00:00 2001 From: Aaron Wong <6979793+zzwong@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:31:49 -0400 Subject: [PATCH 1/2] feat(jtk): add comments update to edit an existing comment Closes #498. --- skills/Jira/Workflows/ManageComments.md | 14 ++- tools/jtk/CHANGELOG.md | 1 + tools/jtk/README.md | 21 ++++ tools/jtk/api/comments.go | 27 +++++ tools/jtk/api/comments_test.go | 52 +++++++++ tools/jtk/integration-tests.md | 6 ++ tools/jtk/internal/cmd/OUTPUT_SPEC.md | 10 +- tools/jtk/internal/cmd/comments/comments.go | 39 +++++++ .../internal/cmd/comments/comments_test.go | 100 ++++++++++++++++++ 9 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 tools/jtk/api/comments_test.go diff --git a/skills/Jira/Workflows/ManageComments.md b/skills/Jira/Workflows/ManageComments.md index 39f2a379..224f9aa4 100644 --- a/skills/Jira/Workflows/ManageComments.md +++ b/skills/Jira/Workflows/ManageComments.md @@ -1,6 +1,6 @@ # ManageComments Workflow -View and add comments on Jira issues. +View, add, update, and delete comments on Jira issues. ## Intent-to-Flag Mapping @@ -10,6 +10,7 @@ View and add comments on Jira issues. |-----------|---------|----------| | "view comments", "list comments", "show comments" | `jtk comments list PROJ-123` | Issue key | | "add comment", "comment on", "post comment" | `jtk comments add PROJ-123 --body "TEXT"` | Issue key + `--body` text | +| "edit comment", "update comment", "fix comment" | `jtk comments update PROJ-123 COMMENT_ID --body "TEXT"` | Issue key + comment ID + `--body` text | | "delete comment", "remove comment" | `jtk comments delete PROJ-123 COMMENT_ID` | Issue key + comment ID | ## Execute @@ -43,6 +44,16 @@ Line 3" jtk comments add PROJ-123 --body "Line 1\nLine 2\nLine 3" ``` +### Update Comment + +Replaces the whole body of an existing comment; the comment keeps its ID and position. `--body` is handled exactly like `comments add`. + +```bash +jtk comments update PROJ-123 COMMENT_ID --body "Corrected text" +``` + +Prefer this over delete-and-re-add, which changes the comment ID and moves the comment to the end of the thread. If the user refers to the comment by content or author, list comments first to recover the ID. + ### Delete Comment **Agent must confirm with user before calling this command.** `jtk comments delete` executes immediately with no interactive CLI-level prompt. The operation is destructive and cannot be undone; the agent is responsible for the pre-call confirmation. @@ -57,6 +68,7 @@ The `COMMENT_ID` is a numeric ID (e.g., `12345`). If the user refers to the comm - **List comments:** A table with ID, author, timestamp, and body; `--fulltext` preserves these columns - **Add comment:** Confirm comment was added with issue key and a snippet of the comment text +- **Update comment:** Same detail block as add, with the unchanged comment ID ## Post-Action diff --git a/tools/jtk/CHANGELOG.md b/tools/jtk/CHANGELOG.md index b3d65dd0..d87ef419 100644 --- a/tools/jtk/CHANGELOG.md +++ b/tools/jtk/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `comments update --body` replaces the body of an existing comment in place, with the same markdown, escape-sequence, and raw ADF handling as `comments add`. ([#498](https://github.com/open-cli-collective/atlassian-cli/issues/498)) - Raw ADF JSON passthrough for `--description` (`issues create`/`update`), `--body` (`comments add`), and rich-text `--field` overrides: a value that parses as `{"type":"doc","version":1,...}` with content is sent as a structured ADF document instead of going through markdown conversion, so documents with node types markdown can't express (e.g. `inlineCard` smart links) can be written directly. ([#486](https://github.com/open-cli-collective/atlassian-cli/pull/486)) - `issues get` now accepts multiple issue keys and renders a summary table for the batch. ([#327](https://github.com/open-cli-collective/atlassian-cli/pull/327)) - `issues check ` subcommand to audit an issue for populated/missing field values, with `--require` (hard-fail) and `--warn` (advisory) flags. A curated default warn-list (Summary, Description, Assignee, Priority, Labels, Story Points, Sprint, Components, Fix Version/s) applies when no flags are passed. Useful as a transition guardrail or CI step. diff --git a/tools/jtk/README.md b/tools/jtk/README.md index d69d4f3b..7f134fc2 100644 --- a/tools/jtk/README.md +++ b/tools/jtk/README.md @@ -804,6 +804,27 @@ jtk comments add PROJ-123 --body "Line one\nLine two\n\tIndented line" --- +### `jtk comments update ` + +Replace the body of an existing comment. The comment keeps its ID and position in the thread. + +**Aliases:** `jtk comment update`, `jtk c update` + +```bash +jtk comments update PROJ-123 10042 --body "Corrected text" +jtk comments update PROJ-123 10042 --body "$(cat comment.adf.json)" +``` + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--body` | `-b` | | New comment text, handled the same way as `comments add --body` (**required**) | + +**Arguments:** +- `` - The issue key (**required**) +- `` - The comment ID (**required**) + +--- + ### `jtk comments delete ` Delete a comment from an issue. diff --git a/tools/jtk/api/comments.go b/tools/jtk/api/comments.go index d1259f0e..879d1ae4 100644 --- a/tools/jtk/api/comments.go +++ b/tools/jtk/api/comments.go @@ -60,6 +60,33 @@ func (c *Client) AddComment(ctx context.Context, issueKey, commentBody string) ( return &comment, nil } +// UpdateComment replaces the body of an existing comment +func (c *Client) UpdateComment(ctx context.Context, issueKey, commentID, commentBody string) (*Comment, error) { + if issueKey == "" { + return nil, ErrIssueKeyRequired + } + if commentID == "" { + return nil, ErrCommentIDRequired + } + + urlStr := fmt.Sprintf("%s/issue/%s/comment/%s", c.BaseURL, url.PathEscape(issueKey), url.PathEscape(commentID)) + req := AddCommentRequest{ + Body: NewADFDocument(commentBody), + } + + body, err := c.Put(ctx, urlStr, req) + if err != nil { + return nil, fmt.Errorf("updating comment %s: %w", commentID, err) + } + + var comment Comment + if err := json.Unmarshal(body, &comment); err != nil { + return nil, fmt.Errorf("parsing comment: %w", err) + } + + return &comment, nil +} + // DeleteComment deletes a comment from an issue func (c *Client) DeleteComment(ctx context.Context, issueKey, commentID string) error { if issueKey == "" { diff --git a/tools/jtk/api/comments_test.go b/tools/jtk/api/comments_test.go new file mode 100644 index 00000000..0e91cf24 --- /dev/null +++ b/tools/jtk/api/comments_test.go @@ -0,0 +1,52 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/open-cli-collective/atlassian-go/testutil" +) + +func TestUpdateComment(t *testing.T) { + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testutil.Equal(t, r.URL.Path, "/rest/api/3/issue/PROJ-123/comment/10001") + testutil.Equal(t, r.Method, http.MethodPut) + capturedBody, _ = io.ReadAll(r.Body) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "10001", + "author": map[string]any{"displayName": "Alice"}, + "created": "2024-06-15T10:00:00.000+0000", + "updated": "2024-06-16T10:00:00.000+0000", + }) + })) + defer server.Close() + + client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + testutil.RequireNoError(t, err) + + comment, err := client.UpdateComment(context.Background(), "PROJ-123", "10001", "Corrected text") + testutil.RequireNoError(t, err) + testutil.Equal(t, comment.ID, "10001") + testutil.Equal(t, comment.Author.DisplayName, "Alice") + + var req map[string]any + testutil.RequireNoError(t, json.Unmarshal(capturedBody, &req)) + body := req["body"].(map[string]any) + testutil.Equal(t, body["type"], "doc") + testutil.Len(t, body["content"].([]any), 1) +} + +func TestUpdateComment_EmptyIssueKey(t *testing.T) { + _, err := (&Client{}).UpdateComment(context.Background(), "", "10001", "x") + testutil.Equal(t, err, ErrIssueKeyRequired) +} + +func TestUpdateComment_EmptyCommentID(t *testing.T) { + _, err := (&Client{}).UpdateComment(context.Background(), "PROJ-123", "", "x") + testutil.Equal(t, err, ErrCommentIDRequired) +} diff --git a/tools/jtk/integration-tests.md b/tools/jtk/integration-tests.md index a1be315f..c3798cf8 100644 --- a/tools/jtk/integration-tests.md +++ b/tools/jtk/integration-tests.md @@ -460,6 +460,12 @@ Run these steps in order. Each step depends on the previous. Expected: Comment detail block (Issue Key, Comment ID, Author, body excerpt) Capture the comment ID → `$COMMENT_ID` + Update the comment in place and confirm the ID is unchanged: + ```bash + jtk comments update $TEST_ISSUE $COMMENT_ID -b "Updated comment text" + ``` + Expected: Comment detail block with the same comment ID and the new body + Also test `--id` variant. Capture the ID so it can be cleaned up: ```bash COMMENT_ID_2=$(jtk comments add $TEST_ISSUE -b "ID flag test comment" --id) diff --git a/tools/jtk/internal/cmd/OUTPUT_SPEC.md b/tools/jtk/internal/cmd/OUTPUT_SPEC.md index 5b2fbb8b..bdda0957 100644 --- a/tools/jtk/internal/cmd/OUTPUT_SPEC.md +++ b/tools/jtk/internal/cmd/OUTPUT_SPEC.md @@ -520,7 +520,7 @@ Deleted MON-4820 Multi-delete: one line per deleted issue. -### `comments add / delete` +### `comments add / update / delete` ``` $ jtk comments add MON-4810 --body "Noting that this needs QA review on Safari 16." @@ -533,6 +533,14 @@ $ jtk comments add MON-4810 --body "..." --id 21276 ``` +`comments update` renders the same detail block as `comments add`, and `--id` emits the comment ID. + +``` +$ jtk comments update PROJ-123 21276 --body "Corrected text" +PROJ-123 #21276 — Jane Doe, 2026-04-16 +Corrected text +``` + ``` $ jtk comments delete MON-4810 21276 Deleted comment 21276 from MON-4810 diff --git a/tools/jtk/internal/cmd/comments/comments.go b/tools/jtk/internal/cmd/comments/comments.go index f97fde8e..aa956c80 100644 --- a/tools/jtk/internal/cmd/comments/comments.go +++ b/tools/jtk/internal/cmd/comments/comments.go @@ -32,6 +32,7 @@ func Register(parent *cobra.Command, opts *root.Options) { cmd.AddCommand(newListCmd(opts)) cmd.AddCommand(newAddCmd(opts)) + cmd.AddCommand(newUpdateCmd(opts)) cmd.AddCommand(newDeleteCmd(opts)) parent.AddCommand(cmd) @@ -195,6 +196,44 @@ func runAdd(ctx context.Context, opts *root.Options, issueKey, body string) erro return jtkpresent.Emit(opts, jtkpresent.CommentPresenter{}.PresentAddedDetail(issueKey, comment)) } +func newUpdateCmd(opts *root.Options) *cobra.Command { + var body string + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a comment on an issue", + Long: "Replace the body of an existing comment. The comment keeps its ID and position in the thread.", + Example: ` jtk comments update PROJ-123 12345 --body "Corrected text"`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runUpdate(cmd.Context(), opts, args[0], args[1], body) + }, + } + + cmd.Flags().StringVarP(&body, "body", "b", "", "New comment text (raw ADF JSON is sent as structured ADF) (required)") + _ = cmd.MarkFlagRequired("body") + + return cmd +} + +func runUpdate(ctx context.Context, opts *root.Options, issueKey, commentID, body string) error { + client, err := opts.APIClient() + if err != nil { + return err + } + + comment, err := client.UpdateComment(ctx, issueKey, commentID, text.InterpretEscapesUnlessRawADF(body)) + if err != nil { + return err + } + + if opts.EmitIDOnly() { + return jtkpresent.EmitIDs(opts, []string{comment.ID}) + } + + return jtkpresent.Emit(opts, jtkpresent.CommentPresenter{}.PresentAddedDetail(issueKey, comment)) +} + func newDeleteCmd(opts *root.Options) *cobra.Command { cmd := &cobra.Command{ Use: "delete ", diff --git a/tools/jtk/internal/cmd/comments/comments_test.go b/tools/jtk/internal/cmd/comments/comments_test.go index 81d8d8b0..889d4f10 100644 --- a/tools/jtk/internal/cmd/comments/comments_test.go +++ b/tools/jtk/internal/cmd/comments/comments_test.go @@ -751,3 +751,103 @@ func TestRunDelete_EmitsText(t *testing.T) { testutil.Equal(t, stdout.String(), "Deleted comment 12345 from PROJ-1\n") testutil.Equal(t, stderr.String(), "") } + +func TestNewUpdateCmd_RequiresBodyAndTwoArgs(t *testing.T) { + t.Parallel() + cmd := newUpdateCmd(&root.Options{}) + testutil.Equal(t, cmd.Use, "update ") + testutil.Error(t, cmd.Args(cmd, []string{"PROJ-1"})) + testutil.NoError(t, cmd.Args(cmd, []string{"PROJ-1", "10001"})) + + flag := cmd.Flags().Lookup("body") + testutil.NotNil(t, flag) + testutil.Equal(t, flag.Shorthand, "b") + + cmd.SetArgs([]string{"PROJ-1", "10001"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + err := cmd.Execute() + testutil.Error(t, err) + testutil.Contains(t, err.Error(), `required flag(s) "body" not set`) +} + +func TestRunUpdate_PostState(t *testing.T) { + t.Parallel() + var method, path string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method, path = r.Method, r.URL.Path + _ = json.NewEncoder(w).Encode(api.Comment{ + ID: "10001", + Author: api.User{DisplayName: "Alice"}, + Created: "2024-06-15T10:00:00Z", + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.com", APIToken: "token"}) + testutil.RequireNoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runUpdate(context.Background(), opts, "PROJ-1", "10001", "Corrected text") + testutil.RequireNoError(t, err) + testutil.Equal(t, method, http.MethodPut) + testutil.Equal(t, path, "/rest/api/3/issue/PROJ-1/comment/10001") + testutil.Contains(t, stdout.String(), "PROJ-1 #10001") + testutil.Contains(t, stdout.String(), "Alice") +} + +func TestRunUpdate_IDOnly(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(api.Comment{ID: "10001"}) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.com", APIToken: "token"}) + testutil.RequireNoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}, IDOnly: true} + opts.SetAPIClient(client) + + err = runUpdate(context.Background(), opts, "PROJ-1", "10001", "Corrected text") + testutil.RequireNoError(t, err) + testutil.Equal(t, stdout.String(), "10001\n") +} + +// TestRunUpdate_RawADFBodyPassthrough mirrors TestRunAdd_RawADFBodyPassthrough: +// a raw ADF --body, including an escaped "\n" and an inlineCard, must reach +// the API unmodified. +func TestRunUpdate_RawADFBodyPassthrough(t *testing.T) { + t.Parallel() + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + _ = json.NewEncoder(w).Encode(api.Comment{ID: "10001"}) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.com", APIToken: "token"}) + testutil.RequireNoError(t, err) + + rawADF := `{"type":"doc","version":1,"content":[{"type":"paragraph","content":[` + + `{"type":"text","text":"Line one\nLine two"},` + + `{"type":"inlineCard","attrs":{"url":"https://example.com/doc"}}]}]}` + + opts := &root.Options{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runUpdate(context.Background(), opts, "PROJ-1", "10001", rawADF) + testutil.RequireNoError(t, err) + + var req map[string]any + testutil.RequireNoError(t, json.Unmarshal(capturedBody, &req)) + para := req["body"].(map[string]any)["content"].([]any)[0].(map[string]any) + nodes := para["content"].([]any) + testutil.Len(t, nodes, 2) + testutil.Equal(t, nodes[0].(map[string]any)["text"], "Line one\nLine two") + testutil.Equal(t, nodes[1].(map[string]any)["type"], "inlineCard") +} From 5ded6aea3413ec8b96c8a18a873806ac0706a763 Mon Sep 17 00:00:00 2001 From: Aaron Wong <6979793+zzwong@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:37:44 -0400 Subject: [PATCH 2/2] fix(jtk): confirm before comments update overwrites a comment --- skills/Jira/Workflows/ManageComments.md | 6 +- tools/jtk/CHANGELOG.md | 2 +- tools/jtk/README.md | 5 +- tools/jtk/integration-tests.md | 2 +- tools/jtk/internal/cmd/OUTPUT_SPEC.md | 2 +- tools/jtk/internal/cmd/comments/comments.go | 35 +++++++-- .../internal/cmd/comments/comments_test.go | 78 ++++++++++++++++++- tools/jtk/internal/present/comment.go | 13 ++++ 8 files changed, 125 insertions(+), 18 deletions(-) diff --git a/skills/Jira/Workflows/ManageComments.md b/skills/Jira/Workflows/ManageComments.md index 224f9aa4..3d1ada9e 100644 --- a/skills/Jira/Workflows/ManageComments.md +++ b/skills/Jira/Workflows/ManageComments.md @@ -10,7 +10,7 @@ View, add, update, and delete comments on Jira issues. |-----------|---------|----------| | "view comments", "list comments", "show comments" | `jtk comments list PROJ-123` | Issue key | | "add comment", "comment on", "post comment" | `jtk comments add PROJ-123 --body "TEXT"` | Issue key + `--body` text | -| "edit comment", "update comment", "fix comment" | `jtk comments update PROJ-123 COMMENT_ID --body "TEXT"` | Issue key + comment ID + `--body` text | +| "edit comment", "update comment", "fix comment" | `jtk comments update PROJ-123 COMMENT_ID --body "TEXT" --force` | Issue key + comment ID + `--body` text | | "delete comment", "remove comment" | `jtk comments delete PROJ-123 COMMENT_ID` | Issue key + comment ID | ## Execute @@ -46,10 +46,10 @@ jtk comments add PROJ-123 --body "Line 1\nLine 2\nLine 3" ### Update Comment -Replaces the whole body of an existing comment; the comment keeps its ID and position. `--body` is handled exactly like `comments add`. +**Agent must confirm with user before calling this command.** It replaces the whole body of an existing comment and the previous body cannot be recovered; the comment keeps its ID and position. `--body` is handled exactly like `comments add`. The command prompts unless `--force` is passed, so after the user confirms, pass `--force`: ```bash -jtk comments update PROJ-123 COMMENT_ID --body "Corrected text" +jtk comments update PROJ-123 COMMENT_ID --body "Corrected text" --force ``` Prefer this over delete-and-re-add, which changes the comment ID and moves the comment to the end of the thread. If the user refers to the comment by content or author, list comments first to recover the ID. diff --git a/tools/jtk/CHANGELOG.md b/tools/jtk/CHANGELOG.md index d87ef419..c266cb8a 100644 --- a/tools/jtk/CHANGELOG.md +++ b/tools/jtk/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `comments update --body` replaces the body of an existing comment in place, with the same markdown, escape-sequence, and raw ADF handling as `comments add`. ([#498](https://github.com/open-cli-collective/atlassian-cli/issues/498)) +- `comments update --body` replaces the body of an existing comment in place, with the same markdown, escape-sequence, and raw ADF handling as `comments add`. Prompts for confirmation because the previous body is not recoverable; `--force` skips the prompt. ([#498](https://github.com/open-cli-collective/atlassian-cli/issues/498)) - Raw ADF JSON passthrough for `--description` (`issues create`/`update`), `--body` (`comments add`), and rich-text `--field` overrides: a value that parses as `{"type":"doc","version":1,...}` with content is sent as a structured ADF document instead of going through markdown conversion, so documents with node types markdown can't express (e.g. `inlineCard` smart links) can be written directly. ([#486](https://github.com/open-cli-collective/atlassian-cli/pull/486)) - `issues get` now accepts multiple issue keys and renders a summary table for the batch. ([#327](https://github.com/open-cli-collective/atlassian-cli/pull/327)) - `issues check ` subcommand to audit an issue for populated/missing field values, with `--require` (hard-fail) and `--warn` (advisory) flags. A curated default warn-list (Summary, Description, Assignee, Priority, Labels, Story Points, Sprint, Components, Fix Version/s) applies when no flags are passed. Useful as a transition guardrail or CI step. diff --git a/tools/jtk/README.md b/tools/jtk/README.md index 7f134fc2..4c55bc5c 100644 --- a/tools/jtk/README.md +++ b/tools/jtk/README.md @@ -806,18 +806,19 @@ jtk comments add PROJ-123 --body "Line one\nLine two\n\tIndented line" ### `jtk comments update ` -Replace the body of an existing comment. The comment keeps its ID and position in the thread. +Replace the body of an existing comment. The comment keeps its ID and position in the thread. The previous body cannot be recovered, so the command prompts for confirmation unless `--force` is passed. **Aliases:** `jtk comment update`, `jtk c update` ```bash jtk comments update PROJ-123 10042 --body "Corrected text" -jtk comments update PROJ-123 10042 --body "$(cat comment.adf.json)" +jtk comments update PROJ-123 10042 --body "$(cat comment.adf.json)" --force ``` | Flag | Short | Default | Description | |------|-------|---------|-------------| | `--body` | `-b` | | New comment text, handled the same way as `comments add --body` (**required**) | +| `--force` | | `false` | Skip the confirmation prompt (required with `--non-interactive`) | **Arguments:** - `` - The issue key (**required**) diff --git a/tools/jtk/integration-tests.md b/tools/jtk/integration-tests.md index c3798cf8..beecefd4 100644 --- a/tools/jtk/integration-tests.md +++ b/tools/jtk/integration-tests.md @@ -462,7 +462,7 @@ Run these steps in order. Each step depends on the previous. Update the comment in place and confirm the ID is unchanged: ```bash - jtk comments update $TEST_ISSUE $COMMENT_ID -b "Updated comment text" + jtk comments update $TEST_ISSUE $COMMENT_ID -b "Updated comment text" --force ``` Expected: Comment detail block with the same comment ID and the new body diff --git a/tools/jtk/internal/cmd/OUTPUT_SPEC.md b/tools/jtk/internal/cmd/OUTPUT_SPEC.md index bdda0957..199d62f6 100644 --- a/tools/jtk/internal/cmd/OUTPUT_SPEC.md +++ b/tools/jtk/internal/cmd/OUTPUT_SPEC.md @@ -536,7 +536,7 @@ $ jtk comments add MON-4810 --body "..." --id `comments update` renders the same detail block as `comments add`, and `--id` emits the comment ID. ``` -$ jtk comments update PROJ-123 21276 --body "Corrected text" +$ jtk comments update PROJ-123 21276 --body "Corrected text" --force PROJ-123 #21276 — Jane Doe, 2026-04-16 Corrected text ``` diff --git a/tools/jtk/internal/cmd/comments/comments.go b/tools/jtk/internal/cmd/comments/comments.go index aa956c80..6051e212 100644 --- a/tools/jtk/internal/cmd/comments/comments.go +++ b/tools/jtk/internal/cmd/comments/comments.go @@ -3,10 +3,13 @@ package comments import ( "context" + "fmt" "strconv" "github.com/spf13/cobra" + "github.com/open-cli-collective/atlassian-go/prompt" + "github.com/open-cli-collective/jira-ticket-cli/api" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" jtkpresent "github.com/open-cli-collective/jira-ticket-cli/internal/present" @@ -198,25 +201,43 @@ func runAdd(ctx context.Context, opts *root.Options, issueKey, body string) erro func newUpdateCmd(opts *root.Options) *cobra.Command { var body string + var force bool cmd := &cobra.Command{ - Use: "update ", - Short: "Update a comment on an issue", - Long: "Replace the body of an existing comment. The comment keeps its ID and position in the thread.", - Example: ` jtk comments update PROJ-123 12345 --body "Corrected text"`, - Args: cobra.ExactArgs(2), + Use: "update ", + Short: "Update a comment on an issue", + Long: "Replace the body of an existing comment. The comment keeps its ID and position in the thread. The previous body cannot be recovered.", + Example: ` # Update a comment (will prompt for confirmation) + jtk comments update PROJ-123 12345 --body "Corrected text" + + # Update without confirmation + jtk comments update PROJ-123 12345 --body "Corrected text" --force`, + Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - return runUpdate(cmd.Context(), opts, args[0], args[1], body) + return runUpdate(cmd.Context(), opts, args[0], args[1], body, force) }, } cmd.Flags().StringVarP(&body, "body", "b", "", "New comment text (raw ADF JSON is sent as structured ADF) (required)") _ = cmd.MarkFlagRequired("body") + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") return cmd } -func runUpdate(ctx context.Context, opts *root.Options, issueKey, commentID, body string) error { +func runUpdate(ctx context.Context, opts *root.Options, issueKey, commentID, body string, force bool) error { + if !force && !opts.NonInteractive { + fmt.Fprintf(opts.Stderr, "This will replace the body of comment %s on %s. The previous body cannot be recovered.\n", commentID, issueKey) + fmt.Fprint(opts.Stderr, "Are you sure? [y/N]: ") + } + confirmed, err := prompt.ConfirmOrFail(force, opts.NonInteractive, opts.Stdin) + if err != nil { + return err + } + if !confirmed { + return jtkpresent.Emit(opts, jtkpresent.CommentPresenter{}.PresentUpdateCancelled()) + } + client, err := opts.APIClient() if err != nil { return err diff --git a/tools/jtk/internal/cmd/comments/comments_test.go b/tools/jtk/internal/cmd/comments/comments_test.go index 889d4f10..90fda78a 100644 --- a/tools/jtk/internal/cmd/comments/comments_test.go +++ b/tools/jtk/internal/cmd/comments/comments_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -11,6 +12,7 @@ import ( "sync/atomic" "testing" + "github.com/open-cli-collective/atlassian-go/prompt" "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/jira-ticket-cli/api" @@ -763,6 +765,10 @@ func TestNewUpdateCmd_RequiresBodyAndTwoArgs(t *testing.T) { testutil.NotNil(t, flag) testutil.Equal(t, flag.Shorthand, "b") + force := cmd.Flags().Lookup("force") + testutil.NotNil(t, force) + testutil.Equal(t, force.Shorthand, "") + cmd.SetArgs([]string{"PROJ-1", "10001"}) cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) @@ -791,7 +797,7 @@ func TestRunUpdate_PostState(t *testing.T) { opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}} opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-1", "10001", "Corrected text") + err = runUpdate(context.Background(), opts, "PROJ-1", "10001", "Corrected text", true) testutil.RequireNoError(t, err) testutil.Equal(t, method, http.MethodPut) testutil.Equal(t, path, "/rest/api/3/issue/PROJ-1/comment/10001") @@ -813,7 +819,7 @@ func TestRunUpdate_IDOnly(t *testing.T) { opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}, IDOnly: true} opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-1", "10001", "Corrected text") + err = runUpdate(context.Background(), opts, "PROJ-1", "10001", "Corrected text", true) testutil.RequireNoError(t, err) testutil.Equal(t, stdout.String(), "10001\n") } @@ -840,7 +846,7 @@ func TestRunUpdate_RawADFBodyPassthrough(t *testing.T) { opts := &root.Options{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} opts.SetAPIClient(client) - err = runUpdate(context.Background(), opts, "PROJ-1", "10001", rawADF) + err = runUpdate(context.Background(), opts, "PROJ-1", "10001", rawADF, true) testutil.RequireNoError(t, err) var req map[string]any @@ -851,3 +857,69 @@ func TestRunUpdate_RawADFBodyPassthrough(t *testing.T) { testutil.Equal(t, nodes[0].(map[string]any)["text"], "Line one\nLine two") testutil.Equal(t, nodes[1].(map[string]any)["type"], "inlineCard") } + +func newUpdateTestServer(t *testing.T, calls *atomic.Int32) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + _ = json.NewEncoder(w).Encode(api.Comment{ID: "10001", Author: api.User{DisplayName: "Alice"}}) + })) + t.Cleanup(server.Close) + return server +} + +func TestRunUpdate_PromptDeclined_DoesNotWrite(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + server := newUpdateTestServer(t, &calls) + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.com", APIToken: "token"}) + testutil.RequireNoError(t, err) + + var stdout, stderr bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &stderr, Stdin: strings.NewReader("n\n")} + opts.SetAPIClient(client) + + err = runUpdate(context.Background(), opts, "PROJ-1", "10001", "Corrected text", false) + testutil.RequireNoError(t, err) + testutil.Equal(t, calls.Load(), int32(0)) + testutil.Contains(t, stderr.String(), "replace the body of comment 10001 on PROJ-1") + testutil.Equal(t, stdout.String(), "Update cancelled.\n") +} + +func TestRunUpdate_PromptConfirmed_Writes(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + server := newUpdateTestServer(t, &calls) + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.com", APIToken: "token"}) + testutil.RequireNoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}, Stdin: strings.NewReader("y\n")} + opts.SetAPIClient(client) + + err = runUpdate(context.Background(), opts, "PROJ-1", "10001", "Corrected text", false) + testutil.RequireNoError(t, err) + testutil.Equal(t, calls.Load(), int32(1)) + testutil.Contains(t, stdout.String(), "PROJ-1 #10001") +} + +func TestRunUpdate_NonInteractiveWithoutForce_FailsWithoutWriting(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + server := newUpdateTestServer(t, &calls) + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.com", APIToken: "token"}) + testutil.RequireNoError(t, err) + + var stderr bytes.Buffer + opts := &root.Options{Stdout: &bytes.Buffer{}, Stderr: &stderr, Stdin: strings.NewReader(""), NonInteractive: true} + opts.SetAPIClient(client) + + err = runUpdate(context.Background(), opts, "PROJ-1", "10001", "Corrected text", false) + testutil.Error(t, err) + testutil.True(t, errors.Is(err, prompt.ErrConfirmationRequired)) + testutil.Equal(t, calls.Load(), int32(0)) + testutil.Equal(t, stderr.String(), "") +} diff --git a/tools/jtk/internal/present/comment.go b/tools/jtk/internal/present/comment.go index b403aeec..8ba9f8ab 100644 --- a/tools/jtk/internal/present/comment.go +++ b/tools/jtk/internal/present/comment.go @@ -118,6 +118,19 @@ func (CommentPresenter) PresentAdded(commentID, issueKey string) *present.Output } } +// PresentUpdateCancelled creates an info message for a declined update. +func (CommentPresenter) PresentUpdateCancelled() *present.OutputModel { + return &present.OutputModel{ + Sections: []present.Section{ + &present.MessageSection{ + Kind: present.MessageInfo, + Message: "Update cancelled.", + Stream: present.StreamStdout, + }, + }, + } +} + // PresentDeleted creates a success message for comment deletion. func (CommentPresenter) PresentDeleted(commentID, issueKey string) *present.OutputModel { return &present.OutputModel{