From b6d247dd4e7ef2857314c0c0106a15b282dc459a Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 18 Jul 2026 15:46:30 -0400 Subject: [PATCH 1/2] feat: surface inline-comment anchor fields on pr view --reviews (task 0034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each inline review comment under `pr view --reviews` now renders its `id` (the handle a reply targets), `resolved` (`yes`/`no`, from whether Gitea populated the comment's `resolver`), and `diff_hunk`. The hunk is structurally trimmed to its `@@` header line plus its last two lines by default (hunks of three lines or fewer are left whole) and emitted verbatim under `--full`, so the trim never touches the char-based body truncation path. The raw `position`/`original_position` diff offsets stay unsurfaced. The fields ride the existing reviews-plus-per-review-comments fetch — no extra API calls. --- .claude/spec/pr-review-comments.md | 88 ++++++ .claude/tasks/0034-pr-review-anchor-fields.md | 40 +++ src/commands/pr.ts | 25 +- src/diff.ts | 17 ++ test/pr-view.test.ts | 257 +++++++++++++++++- 5 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 .claude/spec/pr-review-comments.md create mode 100644 .claude/tasks/0034-pr-review-anchor-fields.md diff --git a/.claude/spec/pr-review-comments.md b/.claude/spec/pr-review-comments.md new file mode 100644 index 0000000..43247c7 --- /dev/null +++ b/.claude/spec/pr-review-comments.md @@ -0,0 +1,88 @@ +## Problem Statement + +An agent doing a PR review round-trip with gitea-axi cannot complete it inside the tool. + +When reading a reviewer's inline comments via `pr view --reviews`, the output gives the author, file path, and body, but not which line each comment is anchored to, nor the comment's id. +Comments like "what does this do?" or "wasn't this set earlier?" are unanswerable from the output alone, forcing a fallback to a raw Gitea API call (and scraping the token out of tea's config) just to recover the anchor. + +When writing, `pr review` accepts only a single `--body` for the whole review, so "reply to each of ten inline comments" collapses into one consolidated body that restates each thread by hand, rather than a reply landing under each comment where the reviewer left it. + +## Solution + +Complete the read and write halves of the inline-review round-trip, both at the existing `pr` commands. + +On the read side, `pr view --reviews` surfaces each inline comment's `id`, its `diff_hunk` (so the anchoring code travels with the comment), and whether the thread is already `resolved`. + +On the write side, `pr review` gains a `--comments-file` flag that carries a batch of inline comments — each either a reply into an existing thread (by comment id) or a fresh comment on a new-file line — mapped onto the review-submission payload Gitea already accepts. + +## User Stories + +1. As a reviewing agent, I want each inline review comment's anchoring `diff_hunk` in the `--reviews` output, so that I can answer a bare "what does this do?" without a second API call. +2. As a reviewing agent, I want each inline review comment's `id` in the `--reviews` output, so that I can target that exact comment when replying. +3. As a reviewing agent, I want to see whether each inline comment's thread is already `resolved`, so that I skip settled conversations instead of re-answering them. +4. As a reviewing agent, I want the `diff_hunk` trimmed to its header line plus its last couple of lines by default, so that I get the file-line anchor and the code at the comment without paying for the whole hunk. +5. As a reviewing agent, I want `--full` to expand each `diff_hunk` to its complete text, so that I can read the entire hunk when the trimmed tail is not enough. +6. As a PR author, I want to reply to an existing inline comment by its id, so that my reply lands in that reviewer's thread without me computing any line number or side. +7. As a PR author, I want to post a fresh inline comment on a new-file line, so that I can raise a point on code no one has commented on yet. +8. As a PR author, I want to submit a batch of inline comments in one file alongside my review, so that "reply to each of ten comments" is one command, not ten. +9. As a PR author, I want gitea-axi to figure out the new-vs-old side of a reply for me, so that I never have to reason about diff sides. +10. As a PR author, I want the inline-comment batch to compose with the existing review action and optional top-level body, so that I can approve/request-changes/comment while attaching inline replies. +11. As a PR author, I want a clear validation error when a reply targets a comment id that isn't on the PR, so that a typo fails fast instead of silently posting nowhere. + +## Implementation Decisions + +### Read side — anchor fields on `pr view --reviews` + +- The `--reviews` review rows add three fields per inline comment: `id`, `diff_hunk`, and `resolved`. +- `resolved` is `yes`/`no`, derived client-side from whether the comment's `resolver` is set (Gitea returns `resolver` as a populated user once a thread is resolved). +- The raw `position` / `original_position` diff offsets are deliberately **not** surfaced — they are diff offsets an agent cannot map to a file line without the patch, and the `diff_hunk` header already carries the human-meaningful line range. +- `diff_hunk` is rendered with a bespoke **structural trim**, not the char-based content-truncation used for bodies: by default, the hunk's `@@` header line plus its last two lines (collapsed when the hunk is three lines or fewer). + This keeps both the file-line anchor (the `@@` header) and the code at the comment (the tail), which the keep-head char truncation would get backwards by dropping the tail. +- Under `--full`, the entire `diff_hunk` is emitted verbatim, consistent with `--full` meaning "no trimming anywhere"; the hunk is never run through the body char-truncation path. +- These fields ride the existing `--reviews` fetch (reviews list plus one inline-comments fetch per review); no extra API calls are introduced. + +### Write side — `--comments-file` on `pr review` + +- `pr review` gains `--comments-file `, a JSON array of inline-comment entries submitted as part of the review; the existing action flag (one of `--approve` / `--request-changes` / `--comment`) is still required, and top-level `--body` stays optional. +- Each array entry is one of two shapes, and there is **no `side` field anywhere**: + - Reply: `{ "reply_to": , "body": "..." }`. + - New comment: `{ "path": "...", "line": , "body": "..." }`. +- A new comment maps `line` to `new_position`; it is always the new side, because a line addressable by new-file number is by definition on the new side. + A prototype against the live host confirmed the create payload treats `new_position` as a **file line number** (not a diff offset) and that a single inline comment posts successfully this way. +- A reply carries no line or side. + gitea-axi locates the target comment (there is no get-comment-by-id endpoint, so it reuses the same reviews-plus-comments fan-out the read side already performs), reconstructs that comment's anchor from its own `diff_hunk`, and posts a matching inline comment; because Gitea threads comments by line, a same-line post joins the existing conversation, so side is inferred from the target rather than supplied. +- All entries map onto the `comments[]` array of the review-submission payload (each element a `{ path, new_position | old_position, body }`), which the SDK already accepts but gitea-axi previously left unpopulated — no new HTTP layer or endpoint is added. +- A `reply_to` id that is not found among the PR's review comments is a `VALIDATION_ERROR` raised before submission, mirroring how `pr review` already validates its action flags up front. +- Mutation output follows the established action-block/entity-block convention for `pr review`; the inline-comment count is reflected in the reported result. + +### Sequencing + +- The read side ships first: it is pure read, and its surfaced `id` is the handle the write side's replies target. +- The write side ships second, on top of the id exposed by the read side. + +## Testing Decisions + +- Both halves are tested at the single existing **fixture-server CLI seam**: a fixture server maps request path/method to recorded Gitea JSON, the built CLI is driven via the run-CLI test helper, and assertions are on rendered `stdout` and on the recorded outbound requests. + No new seam is introduced. +- Good tests here assert external behavior only — the exact rendered TOON lines and the captured request payload — never internal rendering helpers. +- Read side: drive `pr view N --reviews` (and again with `--full`) against fixture reviews and inline comments whose JSON carries `id`, `diff_hunk`, and a set/unset `resolver`; assert the rendered `id`, the trimmed-vs-full `diff_hunk`, and `resolved: yes/no`. + Prior art: the existing `pr view --reviews` test that already stubs the reviews and per-review comments endpoints and asserts the `reviews[...]` block. +- Write side: write a `--comments-file` via the existing temp-file test helper, drive `pr review N --comment --comments-file ` against a stubbed review-submission endpoint, and assert the **captured request body's** `comments[]` (path + `new_position`, and the reply case's reconstructed anchor) plus the action-block output. + Prior art: the existing `pr review` test that inspects the recorded POST body via the fixture server's request log, and the reply case additionally stubs the reviews-plus-comments GETs used for the lookup. +- The reply-lookup failure path is covered by asserting a `VALIDATION_ERROR` and that no submission request was made, matching the existing "rejects ... before any API call" tests. + +## Out of Scope + +- Resolving / unresolving review conversations (issue #39). + Gitea exposes no REST endpoint for this — verified exhaustively against the live host — and the only mechanism is a CSRF-guarded internal web route returning HTML, which a prototype confirmed rejects token auth. + Parked as blocked-upstream; the read side's `resolved` field covers only *seeing* resolution state, not changing it. +- A raw `api` passthrough / generic escape hatch (issue #40); dropped from this work. +- Surfacing raw `position` / `original_position` diff offsets as rendered fields. +- Inline repeatable comment flags (e.g. paired `--on path:line` / `--body`); the JSON `--comments-file` is the sole input shape. +- Old-side *new* comments authored by hand; old-side anchoring is reachable only through the reply path, where it is inferred from the target comment. + +## Further Notes + +- The prototype that validated the write-side `new_position` semantics left one non-removable residue on the live repo: a closed throwaway PR (#41) carrying one review comment, since Gitea cannot hard-delete PRs. +- The "no REST resolve endpoint; web-route-only and CSRF-guarded" finding for the parked #39 is recorded so it is not re-derived. +- Keeping gitea-js as the sole HTTP layer is preserved: dropping the passthrough and confining resolve to out-of-scope means no raw-request path is introduced by this work. diff --git a/.claude/tasks/0034-pr-review-anchor-fields.md b/.claude/tasks/0034-pr-review-anchor-fields.md new file mode 100644 index 0000000..9f5093c --- /dev/null +++ b/.claude/tasks/0034-pr-review-anchor-fields.md @@ -0,0 +1,40 @@ +--- +spec: pr-review-comments +--- + +## What to build + +Extend `pr view --reviews` so each inline review comment carries the anchoring information an agent needs to answer and reply to it without a second API call. +Each inline-comment row gains three fields: `id` (the comment's own id, the handle replies target), `diff_hunk`, and `resolved` (`yes`/`no`, derived client-side from whether the comment's `resolver` user is populated). + +`diff_hunk` renders with a bespoke **structural trim**, distinct from the char-based body truncation: by default the hunk's `@@` header line plus its last two lines, collapsed when the hunk is three lines or fewer. +This keeps both the file-line anchor (the header) and the code at the comment (the tail). +Under `--full` the entire `diff_hunk` is emitted verbatim — the hunk is never run through the body char-truncation path. + +The raw `position` / `original_position` diff offsets are deliberately not surfaced. +These fields ride the existing `--reviews` fetch (reviews list plus one inline-comments fetch per review); no extra API calls are introduced. + +## Acceptance criteria + +- [x] Each inline-comment row under `--reviews` renders `id`, `diff_hunk`, and `resolved` +- [x] `resolved` is `yes` when the comment's `resolver` is populated and `no` otherwise +- [x] Default `diff_hunk` shows the `@@` header line plus the hunk's last two lines; a hunk of three lines or fewer renders in full +- [x] `--full` emits the entire `diff_hunk` verbatim, with no char-truncation applied to it +- [x] Raw `position` / `original_position` are not rendered +- [x] No additional API calls beyond the existing reviews-plus-per-review-comments fetch +- [x] Fixture-server tests drive `pr view N --reviews` and `--full` against reviews and inline comments carrying `id`, `diff_hunk`, and a set/unset `resolver`, asserting the rendered `id`, trimmed-vs-full `diff_hunk`, and `resolved: yes/no` + +## Implementation Notes + +- The structural trim lives in `src/diff.ts` as a pure `trimDiffHunk(hunk)` beside `truncateDiff`: for a hunk longer than three lines it returns the first (`@@` header) line plus the last two lines joined by newline; three lines or fewer is returned unchanged. + `buildReviewRows` in `src/commands/pr.ts` calls it for the default path and passes `diff_hunk` verbatim under `--full`, so the hunk never touches the char-based body-truncation path. +- The inline-comment row's field order is `id, author, path, resolved, diff_hunk, body`, which is the TOON table header the tests assert against. +- `resolved` is `comment.resolver ? "yes" : "no"` — a truthiness check on the SDK's optional `resolver` user, matching the spec's "set / not set" derivation. +- No fresh RED for the `resolved: yes`, `--full`-verbatim, and ≤3-line-full cases: the cohesive `trimDiffHunk` helper (and the `resolver` truthiness branch) implemented in the first GREEN already covered them, so their tests were green on arrival. + Each was proven non-vacuous with a sentinel-probe swap (per the TDD skill) and kept as a regression guard rather than dropped. +- No new API calls: the three fields ride the existing reviews-plus-per-review-comments fan-out that `--reviews` already performs. + +Review follow-ups (`/review-uncommitted`), both addressed in this branch: + +- Standards flagged `id: comment.id ?? 0` as fabricating an identifier — the very handle a reply copies into `reply_to`. Replaced with a `reviewCommentId` helper that throws `UNKNOWN` on a missing id, mirroring the repo's existing `pullNumber`/`headSha` "never invent an identifier" convention. +- Spec flagged the exactly-four-line trim boundary (the shortest hunk the `<= 3` guard actually trims) as untested. Added a regression test for it; a `<= 4` off-by-one would now fail. diff --git a/src/commands/pr.ts b/src/commands/pr.ts index 4cf2da3..6fd9049 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -38,7 +38,7 @@ import { splitFlag, } from "../flags.js"; import { fetchChecks } from "../checks.js"; -import { fetchPullDiff, truncateDiff } from "../diff.js"; +import { fetchPullDiff, trimDiffHunk, truncateDiff } from "../diff.js"; import { checkoutPullHead, currentBranch } from "../git.js"; import { resolveLabelIds, resolveMilestoneId } from "../lookup.js"; import { fetchAllPages, readTotalCount } from "../paginate.js"; @@ -739,6 +739,19 @@ function headSha(pull: PullRequest): string { return sha; } +/** + * The id Gitea gave a review comment — the handle a reply targets. The client + * types it optional, but every real comment has one, and an id invented to fill + * the gap would be reported as fact and copied into a `reply_to`, so a comment + * without one is treated as the broken answer it is (mirroring {@link pullNumber}). + */ +function reviewCommentId(comment: PullReviewComment): number { + if (comment.id === undefined) { + throw axiError("Gitea returned a review comment with no id", "UNKNOWN"); + } + return comment.id; +} + interface PrDetailOptions { host: string; full: boolean; @@ -813,6 +826,13 @@ interface ReviewRowsOptions { * `official`/`stale` flags and its inline (diff) comments. One comments fetch per * review, all in flight at once; review and comment bodies truncate at 800 chars * unless `--full` is set. + * + * Each inline comment carries its anchor: `id` (the reply handle), `resolved` + * (`yes`/`no` from whether Gitea populated the comment's `resolver`), and + * `diff_hunk` — structurally trimmed to its `@@` header plus tail by default, or + * emitted verbatim under `--full`. The raw `position`/`original_position` diff + * offsets are deliberately not surfaced: they are unmappable to a file line + * without the patch, and the `@@` header already carries the line range. */ async function buildReviewRows( api: GiteaClient, @@ -837,8 +857,11 @@ async function buildReviewRows( stale: review.stale ? "yes" : "no", body: truncate(review.body ?? ""), comments: commentLists[index]!.map((comment) => ({ + id: reviewCommentId(comment), author: comment.user?.login ?? "", path: comment.path ?? "", + resolved: comment.resolver ? "yes" : "no", + diff_hunk: options.full ? (comment.diff_hunk ?? "") : trimDiffHunk(comment.diff_hunk ?? ""), body: truncate(comment.body ?? ""), })), })); diff --git a/src/diff.ts b/src/diff.ts index 2edf2db..a7d23a3 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -56,3 +56,20 @@ export function truncateDiff(diff: string, full: boolean): DiffResult { original_length: diff.length, }; } + +/** + * Structurally trim a review comment's `diff_hunk` to its anchor: the `@@` + * header line plus the hunk's last two lines. A hunk of three lines or fewer is + * already covered by that (header + last two), so it is returned unchanged. + * + * This is deliberately not the char-based body truncation: it keeps both the + * file-line anchor (the `@@` header) and the code at the comment (the tail), + * which a keep-head char truncation would get backwards by dropping the tail. + */ +export function trimDiffHunk(hunk: string): string { + const lines = hunk.split("\n"); + if (lines.length <= 3) { + return hunk; + } + return [lines[0], ...lines.slice(-2)].join("\n"); +} diff --git a/test/pr-view.test.ts b/test/pr-view.test.ts index d3a9879..d3151ac 100644 --- a/test/pr-view.test.ts +++ b/test/pr-view.test.ts @@ -169,7 +169,17 @@ describe("pr view", () => { { method: "GET", path: "/api/v1/repos/testowner/testrepo/pulls/7/reviews/11/comments", - body: [{ user: { login: "alice" }, path: "src/x.ts", body: "nit here" }], + body: [ + { + id: 501, + user: { login: "alice" }, + path: "src/x.ts", + body: "nit here", + diff_hunk: "@@ -10,6 +10,7 @@ func main\n a := 1\n b := 2\n c := 3\n d := 4\n+e := 5", + position: 7, + original_position: 4, + }, + ], }, { method: "GET", @@ -194,12 +204,253 @@ describe("pr view", () => { expect(stdout).toContain(" official: no"); expect(stdout).toContain(" stale: no"); expect(stdout).toContain(" stale: yes"); - expect(stdout).toContain(" comments[1]{author,path,body}:"); - expect(stdout).toContain(" alice,src/x.ts,nit here"); + expect(stdout).toContain(" comments[1]{id,author,path,resolved,diff_hunk,body}:"); + expect(stdout).toContain( + ' 501,alice,src/x.ts,no,"@@ -10,6 +10,7 @@ func main\\n d := 4\\n+e := 5",nit here', + ); + expect(stdout).not.toContain("original_position"); + expect(stdout).not.toContain("position:"); expect(stdout).not.toContain("review_count"); expect(stdout).toContain("comment_count"); }); + it("renders resolved as yes when an inline comment's resolver is populated", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/pulls/8", + body: { + number: 8, + title: "T", + state: "open", + user: { login: "alexion" }, + draft: false, + merged: false, + comments: 0, + body: "b", + head: { sha: "sha8", ref: "f" }, + }, + }, + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/pulls/8/reviews", + body: [ + { + id: 21, + state: "COMMENT", + official: false, + stale: false, + dismissed: false, + user: { login: "dave" }, + body: "", + }, + ], + }, + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/pulls/8/reviews/21/comments", + body: [ + { + id: 777, + user: { login: "dave" }, + path: "a.ts", + resolver: { login: "carol" }, + diff_hunk: "@@ -1,4 +1,5 @@\n a\n b\n c\n+d", + body: "ok", + }, + ], + }, + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/commits/sha8/status", + body: { sha: "sha8", total_count: 0, statuses: [] }, + }, + ]); + const { stdout, exitCode } = await runCliTest(["pr", "view", "8", "--reviews"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain('777,dave,a.ts,yes,"@@ -1,4 +1,5 @@\\n c\\n+d",ok'); + }); + + it("emits each inline comment's diff_hunk verbatim under --full, with no trimming", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/pulls/10", + body: { + number: 10, + title: "T", + state: "open", + user: { login: "alexion" }, + draft: false, + merged: false, + comments: 0, + body: "b", + head: { sha: "sha10", ref: "f" }, + }, + }, + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/pulls/10/reviews", + body: [ + { + id: 41, + state: "COMMENT", + official: false, + stale: false, + dismissed: false, + user: { login: "frank" }, + body: "", + }, + ], + }, + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/pulls/10/reviews/41/comments", + body: [ + { + id: 999, + user: { login: "frank" }, + path: "big.ts", + diff_hunk: "@@ -1,4 +1,5 @@\n a\n b\n c\n+d", + body: "hi", + }, + ], + }, + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/commits/sha10/status", + body: { sha: "sha10", total_count: 0, statuses: [] }, + }, + ]); + const { stdout, exitCode } = await runCliTest(["pr", "view", "10", "--reviews", "--full"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain('999,frank,big.ts,no,"@@ -1,4 +1,5 @@\\n a\\n b\\n c\\n+d",hi'); + }); + + it("renders a diff_hunk of three lines or fewer in full by default, leaving short hunks untrimmed", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/pulls/9", + body: { + number: 9, + title: "T", + state: "open", + user: { login: "alexion" }, + draft: false, + merged: false, + comments: 0, + body: "b", + head: { sha: "sha9", ref: "f" }, + }, + }, + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/pulls/9/reviews", + body: [ + { + id: 31, + state: "COMMENT", + official: false, + stale: false, + dismissed: false, + user: { login: "eve" }, + body: "", + }, + ], + }, + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/pulls/9/reviews/31/comments", + body: [ + { + id: 888, + user: { login: "eve" }, + path: "b.ts", + diff_hunk: "@@ -5 +5 @@\n-x\n+y", + body: "short", + }, + ], + }, + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/commits/sha9/status", + body: { sha: "sha9", total_count: 0, statuses: [] }, + }, + ]); + const { stdout, exitCode } = await runCliTest(["pr", "view", "9", "--reviews"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain('888,eve,b.ts,no,"@@ -5 +5 @@\\n-x\\n+y",short'); + }); + + it("trims a 4-line diff_hunk to its header plus last two lines by default, dropping the one middle line", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/pulls/11", + body: { + number: 11, + title: "T", + state: "open", + user: { login: "alexion" }, + draft: false, + merged: false, + comments: 0, + body: "b", + head: { sha: "sha11", ref: "f" }, + }, + }, + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/pulls/11/reviews", + body: [ + { + id: 51, + state: "COMMENT", + official: false, + stale: false, + dismissed: false, + user: { login: "grace" }, + body: "", + }, + ], + }, + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/pulls/11/reviews/51/comments", + body: [ + { + id: 1010, + user: { login: "grace" }, + path: "c.ts", + diff_hunk: "@@ -2,3 +2,3 @@\n keep1\n drop_me\n+keep2", + body: "boundary", + }, + ], + }, + { + method: "GET", + path: "/api/v1/repos/testowner/testrepo/commits/sha11/status", + body: { sha: "sha11", total_count: 0, statuses: [] }, + }, + ]); + const { stdout, exitCode } = await runCliTest(["pr", "view", "11", "--reviews"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain('1010,grace,c.ts,no,"@@ -2,3 +2,3 @@\\n drop_me\\n+keep2",boundary'); + }); + it("reports a nonexistent PR as PR_NOT_FOUND with exit 1", async () => { server = await startFixtureServer([ { -- 2.47.3 From 662ba82d710fb5a48b396d3998a3d3483f19aa2c Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 18 Jul 2026 16:06:53 -0400 Subject: [PATCH 2/2] feat: add --comments-file to pr review for inline comments (task 0035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pr review ` gains `--comments-file `, a JSON array of inline comments submitted with the review. Each entry is one of two exclusive shapes: a new comment `{ path, line, body }` (mapped to `new_position`, always the new side) or a reply `{ reply_to, body }`. A reply carries no line or side — gitea-axi finds the target via the reviews-plus-comments fan-out (there is no get-comment-by-id endpoint), reconstructs its anchor from the target's own `diff_hunk`, and infers old/new side from it, so a same-line post threads with the existing conversation. All entries map onto the review-submission payload's `comments[]`; no new HTTP layer is added. An unknown `reply_to` is a VALIDATION_ERROR raised before the POST, and the submitted inline-comment count rides the action block. The shared path-resolve-and-read behind --body-file and --comments-file is extracted into src/flag-file.ts. --- .../tasks/0035-pr-review-inline-comments.md | 43 +++++ src/body-source.ts | 15 +- src/commands/pr.ts | 19 +- src/diff.ts | 55 +++++- src/flag-file.ts | 25 +++ src/review-comments.ts | 143 ++++++++++++++ src/review.ts | 22 +++ test/pr-review.test.ts | 181 ++++++++++++++++++ 8 files changed, 487 insertions(+), 16 deletions(-) create mode 100644 .claude/tasks/0035-pr-review-inline-comments.md create mode 100644 src/flag-file.ts create mode 100644 src/review-comments.ts diff --git a/.claude/tasks/0035-pr-review-inline-comments.md b/.claude/tasks/0035-pr-review-inline-comments.md new file mode 100644 index 0000000..605b866 --- /dev/null +++ b/.claude/tasks/0035-pr-review-inline-comments.md @@ -0,0 +1,43 @@ +--- +spec: pr-review-comments +blocked-by: 0034-pr-review-anchor-fields +--- + +## What to build + +Give `pr review ` a `--comments-file ` flag carrying a JSON array of inline comments submitted as part of the review. +The existing action flag (exactly one of `--approve` / `--request-changes` / `--comment`) is still required, and top-level `--body` / `--body-file` stays optional, so an agent can approve/request-changes/comment while attaching inline replies. + +Each array entry is one of two shapes, with no `side` field anywhere: + +- New comment: `{ "path": "...", "line": , "body": "..." }` — `line` maps to `new_position`; it is always the new side, because a line addressable by new-file number is by definition on the new side. +- Reply: `{ "reply_to": , "body": "..." }` — carries no line or side. + gitea-axi locates the target comment (no get-comment-by-id endpoint exists, so it reuses the same reviews-plus-comments fan-out the read side performs), reconstructs that comment's anchor from its own `diff_hunk`, and posts a matching inline comment; because Gitea threads comments by line, a same-line post joins the existing conversation, so side is inferred from the target rather than supplied. + +All entries map onto the `comments[]` array of the review-submission payload (each element `{ path, new_position | old_position, body }`), which the SDK already accepts but gitea-axi previously left unpopulated — no new HTTP layer or endpoint is added. +A `reply_to` id not found among the PR's review comments is a `VALIDATION_ERROR` raised before submission, mirroring how `pr review` validates its action flags up front. +Mutation output follows the established action-block/entity-block convention; the inline-comment count is reflected in the reported result. + +## Acceptance criteria + +- [x] `pr review N --comment --comments-file ` submits a review whose payload `comments[]` reflects the file's entries +- [x] A new-comment entry maps `line` to `new_position` on the given `path`, always the new side +- [x] A reply entry (`reply_to`) posts an inline comment whose anchor is reconstructed from the target comment's `diff_hunk`, with side inferred from the target (no `side` field consumed) +- [x] The action flag remains required and top-level `--body` / `--body-file` still composes with the inline batch +- [x] A `reply_to` id absent from the PR's review comments yields `VALIDATION_ERROR` (exit 2) before any submission request is made +- [x] The mutation output reflects the inline-comment count in its result +- [x] Fixture-server tests assert the captured submission body's `comments[]` for both the new-comment (`path` + `new_position`) and reply (reconstructed anchor) cases, the `VALIDATION_ERROR` no-request failure path, and the action-block output + +## Implementation Notes + +- `--comments-file` parsing/validation and payload mapping live in a new module `src/review-comments.ts`. `loadInlineComments` reads and shape-validates the JSON batch up front (before any client is created); `resolveInlineComments` maps entries onto `CreatePullReviewComment[]`. +- The reply anchor is reconstructed by `anchorFromDiffHunk` in `src/diff.ts` (beside `trimDiffHunk`): it walks the unified-diff hunk — whose last line is the commented line, by Gitea's convention — tracking old/new line numbers, and reads the last line off. An added/context last line anchors on `new_position`; a deleted last line on `old_position`. Both sides are covered by tests. +- The reply-target lookup reuses a new `fetchAllReviewComments` in `src/review.ts` — the reviews-plus-comments fan-out the read side already performs — since Gitea has no get-comment-by-id endpoint. It only runs when a reply is present; a new-comment-only batch makes no extra GETs. +- The submitted count rides the action block as `review: { number, action, comments: N }`, added only when a batch was submitted so a plain review's output is unchanged. +- The action-flag-required criterion needed no new test: `resolveReviewAction` still runs first, so the existing zero/multiple-action tests cover it unchanged even with `--comments-file` present. + +Review follow-ups (`/review-uncommitted`), all addressed in this branch: + +- Standards flagged `readCommentsFile` as duplicating `body-source.ts`'s `readBodyFile`. Extracted the shared path-resolve-and-read into `src/flag-file.ts` (`readFlagFile`); both `--body-file` and `--comments-file` now go through it, keeping their flag-specific error messages. +- Spec flagged that an entry mixing both shapes (`reply_to` + `path`/`line`) was silently resolved to a reply. `validateEntry` now rejects the contradictory mix as a `VALIDATION_ERROR` up front (with a regression test), making the two-shape contract exact. +- Spec flagged the `target.path ?? ""` fallback as a silent mis-anchor. A reply target returned without `path`/`diff_hunk` now raises `UNKNOWN` instead of posting an empty path, mirroring the repo's other "never fabricate an anchor/identifier" guards. diff --git a/src/body-source.ts b/src/body-source.ts index d71714d..ce5ba68 100644 --- a/src/body-source.ts +++ b/src/body-source.ts @@ -1,7 +1,6 @@ -import { readFileSync } from "node:fs"; -import { isAbsolute, resolve } from "node:path"; import type { CliDeps } from "./deps.js"; import { axiError } from "./errors.js"; +import { readFlagFile } from "./flag-file.js"; import { flagValue } from "./flags.js"; /** @@ -60,15 +59,5 @@ function bodyFlagSuggestion(command: string): string[] { } function readBodyFile(deps: CliDeps, path: string, command: string): string { - const absolute = isAbsolute(path) ? path : resolve(deps.cwd, path); - try { - return readFileSync(absolute, "utf8"); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - throw axiError( - `Cannot read --body-file ${path}: ${reason}`, - "VALIDATION_ERROR", - bodyFlagSuggestion(command), - ); - } + return readFlagFile(deps, path, "--body-file", bodyFlagSuggestion(command)); } diff --git a/src/commands/pr.ts b/src/commands/pr.ts index 6fd9049..129c05f 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -44,6 +44,7 @@ import { resolveLabelIds, resolveMilestoneId } from "../lookup.js"; import { fetchAllPages, readTotalCount } from "../paginate.js"; import { formatCountLine, renderDetail, renderList, renderScalar, type DetailBlock } from "../render.js"; import { fetchReviewComments, fetchReviewDecision, fetchReviews } from "../review.js"; +import { loadInlineComments, resolveInlineComments } from "../review-comments.js"; import { suggestCommand } from "../suggestions.js"; import { relativeTime } from "../time.js"; @@ -284,6 +285,8 @@ flags: --comment Leave a review comment without approving or rejecting --body Review body --body-file Read the review body from a file (mutually exclusive with --body) + --comments-file JSON array of inline comments to submit with the review; + each entry is {reply_to, body} or {path, line, body} --help Show this help global flags: @@ -1223,15 +1226,18 @@ async function prReview(deps: CliDeps, args: string[]): Promise { "--comment": { takesValue: false }, "--body": { takesValue: true }, "--body-file": { takesValue: true }, + "--comments-file": { takesValue: true }, }, "pr review", ); const number = parsePositionalNumber(positionals, "pr review", "pull request"); // Everything the caller's own input can settle is checked before any request - // goes out: the action flag count first, then the body source. + // goes out: the action flag count first, then the body source, then the + // inline-comment batch (parsed and shape-validated from the file). const chosen = resolveReviewAction(flags); const body = resolveBodySource(deps, flags, "pr review"); + const inlineComments = loadInlineComments(deps, flags, "pr review"); const context = await resolveRepoContext(deps); const api = createClient(context); @@ -1240,15 +1246,24 @@ async function prReview(deps: CliDeps, args: string[]): Promise { if (body !== undefined) { payload.body = body; } + // Replies are resolved against the PR's existing comments before the POST, so + // an unknown `reply_to` fails without a submission ever going out. + if (inlineComments !== undefined && inlineComments.length > 0) { + payload.comments = await resolveInlineComments(api, context, number, inlineComments); + } try { await api.repos.repoCreatePullReview(context.owner, context.name, number, payload); } catch (error) { throw classifyHttpError(error); } + const item: Record = { number, action: chosen.action }; + if (payload.comments !== undefined) { + item.comments = payload.comments.length; + } return renderDetail({ noun: "review", - item: { number, action: chosen.action }, + item, help: [suggestCommand(context, `pr view ${number} --reviews`, "to see the review in full")], }); } diff --git a/src/diff.ts b/src/diff.ts index a7d23a3..7401a85 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -1,6 +1,6 @@ import type { GiteaClient } from "./client.js"; import type { RepoContext } from "./context.js"; -import { classifyHttpError } from "./errors.js"; +import { axiError, classifyHttpError } from "./errors.js"; /** The raw-diff truncation limit, distinct from the body/comment limits. */ export const DIFF_TRUNCATE_LIMIT = 4000; @@ -73,3 +73,56 @@ export function trimDiffHunk(hunk: string): string { } return [lines[0], ...lines.slice(-2)].join("\n"); } + +/** + * The file-line anchor of a review comment, as the side-tagged position the + * review-submission payload wants: exactly one of `new_position` (new side) or + * `old_position` (old side) is set. + */ +export interface HunkAnchor { + new_position?: number; + old_position?: number; +} + +const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/; + +/** + * Reconstruct the file-line anchor of the line a review comment's `diff_hunk` + * belongs to. Gitea builds a comment's `diff_hunk` so it ends at the commented + * line, so the anchor is that last body line: starting from the `@@ -old +new @@` + * header, walk the hunk tracking old- and new-file line numbers, and read the + * last line off. An added (`+`) or context (` `) line anchors on the new side + * (`new_position`); a deleted (`-`) line anchors on the old side (`old_position`). + * + * This lets a reply post a matching inline comment that threads with its target + * without the caller supplying any line or side — Gitea joins comments on the + * same line into one conversation. + */ +export function anchorFromDiffHunk(hunk: string): HunkAnchor { + const lines = hunk.split("\n"); + const header = HUNK_HEADER.exec(lines[0] ?? ""); + if (header === null) { + throw axiError( + "Gitea returned a review comment whose diff_hunk has no @@ header to anchor a reply", + "UNKNOWN", + ); + } + let oldLine = Number(header[1]); + let newLine = Number(header[2]); + // An empty body is degenerate; default to the header's new-side start. + let anchor: HunkAnchor = { new_position: newLine }; + for (const line of lines.slice(1)) { + if (line.startsWith("-")) { + anchor = { old_position: oldLine }; + oldLine += 1; + } else if (line.startsWith("+")) { + anchor = { new_position: newLine }; + newLine += 1; + } else { + anchor = { new_position: newLine }; + oldLine += 1; + newLine += 1; + } + } + return anchor; +} diff --git a/src/flag-file.ts b/src/flag-file.ts new file mode 100644 index 0000000..f2f0f95 --- /dev/null +++ b/src/flag-file.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +import type { CliDeps } from "./deps.js"; +import { axiError } from "./errors.js"; + +/** + * Read the file a path-valued flag points at, resolved against the caller's cwd. + * A missing or unreadable file is a `VALIDATION_ERROR` naming the flag — the + * shared reader behind `--body-file` and `--comments-file`. Parsing the contents + * (as text, JSON, …) is the caller's job; this only turns a path into bytes. + */ +export function readFlagFile( + deps: CliDeps, + path: string, + flag: string, + suggestion: string[], +): string { + const absolute = isAbsolute(path) ? path : resolve(deps.cwd, path); + try { + return readFileSync(absolute, "utf8"); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw axiError(`Cannot read ${flag} ${path}: ${reason}`, "VALIDATION_ERROR", suggestion); + } +} diff --git a/src/review-comments.ts b/src/review-comments.ts new file mode 100644 index 0000000..6947207 --- /dev/null +++ b/src/review-comments.ts @@ -0,0 +1,143 @@ +import type { CreatePullReviewComment } from "gitea-js"; +import type { GiteaClient } from "./client.js"; +import type { RepoContext } from "./context.js"; +import type { CliDeps } from "./deps.js"; +import { anchorFromDiffHunk } from "./diff.js"; +import { axiError } from "./errors.js"; +import { readFlagFile } from "./flag-file.js"; +import { flagValue } from "./flags.js"; +import { fetchAllReviewComments } from "./review.js"; + +/** + * One entry from a `pr review --comments-file` JSON array, in one of two shapes. + * There is deliberately no `side` field: a new comment is always the new side, + * and a reply's side is inferred from the comment it targets. + */ +export type InlineCommentEntry = + | { reply_to: number; body: string } + | { path: string; line: number; body: string }; + +const COMMENTS_FILE_SUGGESTION = [ + "Each entry must be `{ reply_to, body }` or `{ path, line, body }`", +]; + +/** + * Read and validate the `--comments-file` batch, if the flag is present. Returns + * `undefined` when it is absent, so a plain review is unaffected. Every failure — + * a missing/unreadable file, non-JSON, a non-array, or an entry matching neither + * shape — is a `VALIDATION_ERROR` raised here, before any request goes out. + */ +export function loadInlineComments( + deps: CliDeps, + flags: Record, + command: string, +): InlineCommentEntry[] | undefined { + const path = flagValue(flags, "--comments-file"); + if (path === undefined) { + return undefined; + } + return parseInlineComments(readCommentsFile(deps, path, command), command); +} + +function readCommentsFile(deps: CliDeps, path: string, command: string): string { + return readFlagFile(deps, path, "--comments-file", [ + `Run \`gitea-axi ${command} --comments-file \` with a readable JSON file`, + ]); +} + +function parseInlineComments(text: string, command: string): InlineCommentEntry[] { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw axiError(`--comments-file is not valid JSON: ${reason}`, "VALIDATION_ERROR"); + } + if (!Array.isArray(parsed)) { + throw axiError( + "--comments-file must be a JSON array of inline-comment entries", + "VALIDATION_ERROR", + COMMENTS_FILE_SUGGESTION, + ); + } + return parsed.map((entry, index) => validateEntry(entry, index)); +} + +function validateEntry(entry: unknown, index: number): InlineCommentEntry { + const at = `--comments-file entry ${index}`; + if (typeof entry !== "object" || entry === null) { + throw axiError(`${at} must be an object`, "VALIDATION_ERROR", COMMENTS_FILE_SUGGESTION); + } + const record = entry as Record; + if (typeof record.body !== "string") { + throw axiError(`${at} needs a string \`body\``, "VALIDATION_ERROR", COMMENTS_FILE_SUGGESTION); + } + const isReply = record.reply_to !== undefined; + const isNew = record.path !== undefined || record.line !== undefined; + // The two shapes are exclusive: an entry carrying both a `reply_to` and a + // `path`/`line` is contradictory (a reply needs neither), so it is rejected + // rather than silently resolved to one arm. + if (isReply && isNew) { + throw axiError( + `${at} mixes a reply (\`reply_to\`) with a new comment (\`path\`/\`line\`) — use one shape`, + "VALIDATION_ERROR", + COMMENTS_FILE_SUGGESTION, + ); + } + if (isReply) { + if (typeof record.reply_to !== "number") { + throw axiError(`${at} \`reply_to\` must be a number`, "VALIDATION_ERROR", COMMENTS_FILE_SUGGESTION); + } + return { reply_to: record.reply_to, body: record.body }; + } + if (typeof record.path === "string" && typeof record.line === "number") { + return { path: record.path, line: record.line, body: record.body }; + } + throw axiError( + `${at} must be a reply (\`reply_to\`) or a new comment (\`path\` + \`line\`)`, + "VALIDATION_ERROR", + COMMENTS_FILE_SUGGESTION, + ); +} + +/** + * Map validated inline-comment entries onto the review-submission payload's + * `comments[]`. A new comment goes straight through — its new-file `line` + * becomes `new_position` (always the new side). A reply is resolved against the + * PR's existing review comments (one reviews-plus-comments fan-out, only when a + * reply is present): its target is found by id, and that comment's anchor is + * reconstructed from its own `diff_hunk` so the reply threads onto the same + * line. A `reply_to` id absent from the PR is a `VALIDATION_ERROR`. + */ +export async function resolveInlineComments( + api: GiteaClient, + context: RepoContext, + number: number, + entries: InlineCommentEntry[], +): Promise { + const hasReply = entries.some((entry) => "reply_to" in entry); + const existing = hasReply ? await fetchAllReviewComments(api, context, number) : []; + + return entries.map((entry) => { + if ("reply_to" in entry) { + const target = existing.find((comment) => comment.id === entry.reply_to); + if (target === undefined) { + throw axiError( + `--comments-file reply_to ${entry.reply_to} is not a review comment on this pull request`, + "VALIDATION_ERROR", + ); + } + // The target's path and diff_hunk are what re-anchor the reply; a comment + // returned without them is a broken answer, not a reply we can invent an + // anchor for (mirroring the other "never fabricate" guards). + if (target.path === undefined || target.diff_hunk === undefined) { + throw axiError( + `Gitea returned review comment ${entry.reply_to} without the path/diff_hunk needed to anchor a reply`, + "UNKNOWN", + ); + } + return { path: target.path, ...anchorFromDiffHunk(target.diff_hunk), body: entry.body }; + } + return { path: entry.path, new_position: entry.line, body: entry.body }; + }); +} diff --git a/src/review.ts b/src/review.ts index cae25cd..47c67d1 100644 --- a/src/review.ts +++ b/src/review.ts @@ -99,3 +99,25 @@ export async function fetchReviewComments( throw classifyHttpError(error); } } + +/** + * Every inline review comment on a PR, flattened across all its reviews. Gitea + * has no get-comment-by-id endpoint, so the write side's reply path uses this + * reviews-plus-comments fan-out — the same one `pr view --reviews` performs — to + * locate a reply's target comment by id. + */ +export async function fetchAllReviewComments( + api: GiteaClient, + context: RepoContext, + number: number, +): Promise { + const reviews = await fetchReviews(api, context, number); + const lists = await Promise.all( + reviews.map((review) => + review.id !== undefined + ? fetchReviewComments(api, context, number, review.id) + : Promise.resolve([]), + ), + ); + return lists.flat(); +} diff --git a/test/pr-review.test.ts b/test/pr-review.test.ts index e8ecaf7..f3b1a72 100644 --- a/test/pr-review.test.ts +++ b/test/pr-review.test.ts @@ -110,6 +110,187 @@ describe("pr review", () => { expect(reviewPosted()?.body).toEqual({ event: "COMMENT", body: "Looks good" }); }); + it("maps a --comments-file new-comment entry to comments[] with new_position and reports the count", async () => { + server = await startFixtureServer([{ method: "POST", path: REVIEWS_PATH, body: {} }]); + const path = files.write( + "comments.json", + JSON.stringify([{ path: "src/x.ts", line: 42, body: "fresh point" }]), + ); + + const { stdout, exitCode } = await runCliTest( + ["pr", "review", "9", "--comment", "--comments-file", path], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(reviewPosted()?.body).toEqual({ + event: "COMMENT", + comments: [{ path: "src/x.ts", new_position: 42, body: "fresh point" }], + }); + expect(stdout).toContain("action: comment"); + expect(stdout).toContain("number: 9"); + expect(stdout).toContain("comments: 1"); + }); + + it("reconstructs a reply's anchor from the target comment's diff_hunk via the reviews fan-out", async () => { + server = await startFixtureServer([ + { method: "POST", path: REVIEWS_PATH, body: {} }, + { + method: "GET", + path: REVIEWS_PATH, + body: [{ id: 30, state: "COMMENT", user: { login: "rev" } }], + }, + { + method: "GET", + path: `${REVIEWS_PATH}/30/comments`, + body: [ + { + id: 500, + path: "src/a.ts", + diff_hunk: "@@ -10,3 +10,4 @@\n ctxA\n ctxB\n+added", + body: "orig", + }, + ], + }, + ]); + const path = files.write( + "comments.json", + JSON.stringify([{ reply_to: 500, body: "my reply" }]), + ); + + const { stdout, exitCode } = await runCliTest( + ["pr", "review", "9", "--comment", "--comments-file", path], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(reviewPosted()?.body).toEqual({ + event: "COMMENT", + comments: [{ path: "src/a.ts", new_position: 12, body: "my reply" }], + }); + expect(stdout).toContain("comments: 1"); + }); + + it("rejects a reply whose reply_to id is not among the PR's review comments without posting", async () => { + server = await startFixtureServer([ + { method: "POST", path: REVIEWS_PATH, body: {} }, + { + method: "GET", + path: REVIEWS_PATH, + body: [{ id: 30, state: "COMMENT", user: { login: "rev" } }], + }, + { + method: "GET", + path: `${REVIEWS_PATH}/30/comments`, + body: [ + { + id: 500, + path: "src/a.ts", + diff_hunk: "@@ -10,3 +10,4 @@\n ctxA\n ctxB\n+added", + body: "orig", + }, + ], + }, + ]); + const path = files.write( + "comments.json", + JSON.stringify([{ reply_to: 999, body: "reply to nobody" }]), + ); + + const { stdout, exitCode } = await runCliTest( + ["pr", "review", "9", "--comment", "--comments-file", path], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(reviewPosted()).toBeUndefined(); + }); + + it("composes a top-level --body with the inline comments batch in one payload", async () => { + server = await startFixtureServer([{ method: "POST", path: REVIEWS_PATH, body: {} }]); + const path = files.write( + "comments.json", + JSON.stringify([{ path: "src/y.ts", line: 7, body: "inline note" }]), + ); + + const { exitCode } = await runCliTest( + [ + "pr", + "review", + "9", + "--request-changes", + "--body", + "overall: please fix", + "--comments-file", + path, + ], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(reviewPosted()?.body).toEqual({ + event: "REQUEST_CHANGES", + body: "overall: please fix", + comments: [{ path: "src/y.ts", new_position: 7, body: "inline note" }], + }); + }); + + it("anchors a reply on a deleted line to old_position inferred from the target", async () => { + server = await startFixtureServer([ + { method: "POST", path: REVIEWS_PATH, body: {} }, + { + method: "GET", + path: REVIEWS_PATH, + body: [{ id: 40, state: "COMMENT", user: { login: "rev" } }], + }, + { + method: "GET", + path: `${REVIEWS_PATH}/40/comments`, + body: [ + { + id: 700, + path: "src/b.ts", + diff_hunk: "@@ -20,2 +20,1 @@\n ctx1\n-removed", + body: "orig", + }, + ], + }, + ]); + const path = files.write( + "comments.json", + JSON.stringify([{ reply_to: 700, body: "reply on deletion" }]), + ); + + const { exitCode } = await runCliTest( + ["pr", "review", "9", "--comment", "--comments-file", path], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(reviewPosted()?.body).toEqual({ + event: "COMMENT", + comments: [{ path: "src/b.ts", old_position: 21, body: "reply on deletion" }], + }); + }); + + it("rejects a comments-file entry mixing reply_to with path/line before any API call", async () => { + server = await startFixtureServer([]); + const path = files.write( + "comments.json", + JSON.stringify([{ reply_to: 500, path: "src/a.ts", line: 3, body: "confused entry" }]), + ); + + const { stdout, exitCode } = await runCliTest( + ["pr", "review", "9", "--comment", "--comments-file", path], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(server.requests).toHaveLength(0); + }); + it("forwards a --body-file review body from the file contents", async () => { server = await startFixtureServer([{ method: "POST", path: REVIEWS_PATH, body: {} }]); const path = files.write("review.txt", "Please fix the tests"); -- 2.47.3