feat: add --comments-file to pr review for inline comments (task 0035)
All checks were successful
CI / test (pull_request) Successful in 55s
CI / test (push) Successful in 53s

`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.
This commit was merged in pull request #42.
This commit is contained in:
2026-07-18 16:06:53 -04:00
parent b6d247dd4e
commit 662ba82d71
8 changed files with 487 additions and 16 deletions

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

@@ -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 <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:
@@ -1223,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);
@@ -1240,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;
@@ -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;
}

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