diff --git a/skills/Jira/Workflows/ManageComments.md b/skills/Jira/Workflows/ManageComments.md index 39f2a379..3d1ada9e 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" --force` | 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 + +**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" --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. + ### 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..c266cb8a 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`. 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 d69d4f3b..4c55bc5c 100644 --- a/tools/jtk/README.md +++ b/tools/jtk/README.md @@ -804,6 +804,28 @@ 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. 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)" --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**) +- `` - 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..beecefd4 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" --force + ``` + 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..199d62f6 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" --force +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..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" @@ -32,6 +35,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 +199,62 @@ 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 + 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. 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, 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, 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 + } + + 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..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" @@ -751,3 +753,173 @@ 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") + + 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{}) + 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", true) + 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", true) + 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, true) + 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") +} + +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{