Compare commits

...

3 Commits

Author SHA1 Message Date
4e9e4cb9d3 Merge remote-tracking branch 'origin/main' into task-0034-pr-review-anchor-fields
All checks were successful
CI / test (pull_request) Successful in 55s
2026-07-18 16:09:04 -04:00
cb801522a9 feat: add --comments-file to pr review for inline comments (task 0035)
All checks were successful
CI / test (pull_request) Successful in 57s
`pr review <n>` gains `--comments-file <path>`, 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.
2026-07-18 16:06:53 -04:00
237c10e38b feat: surface inline-comment anchor fields on pr view --reviews (task 0034)
All checks were successful
CI / test (pull_request) Successful in 53s
Each inline review comment under `pr view <n> --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.
2026-07-18 15:46:30 -04:00
11 changed files with 910 additions and 20 deletions

View File

@@ -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 <path>`, 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": <comment-id>, "body": "..." }`.
- New comment: `{ "path": "...", "line": <new-file 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 <f>` 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.

View File

@@ -0,0 +1,40 @@
---
spec: pr-review-comments
---
## What to build
Extend `pr view <n> --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.

View File

@@ -0,0 +1,43 @@
---
spec: pr-review-comments
blocked-by: 0034-pr-review-anchor-fields
---
## What to build
Give `pr review <n>` a `--comments-file <path>` 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": <new-file 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": <comment-id>, "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 <f>` 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.

View File

@@ -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));
}

View File

@@ -38,12 +38,13 @@ 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";
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 <text> Review body
--body-file <path> Read the review body from a file (mutually exclusive with --body)
--comments-file <path> 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:
@@ -739,6 +742,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 +829,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 +860,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 ?? ""),
})),
}));
@@ -1200,15 +1226,18 @@ async function prReview(deps: CliDeps, args: string[]): Promise<string> {
"--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);
@@ -1217,15 +1246,24 @@ async function prReview(deps: CliDeps, args: string[]): Promise<string> {
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<string, unknown> = { 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")],
});
}

View File

@@ -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;
@@ -56,3 +56,73 @@ 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");
}
/**
* 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;
}

25
src/flag-file.ts Normal file
View File

@@ -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);
}
}

143
src/review-comments.ts Normal file
View File

@@ -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<string, string | true>,
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 <path>\` 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<string, unknown>;
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<CreatePullReviewComment[]> {
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 };
});
}

View File

@@ -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<PullReviewComment[]> {
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<PullReviewComment[]>([]),
),
);
return lists.flat();
}

View File

@@ -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");

View File

@@ -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([
{