feat: add --comments-file to pr review for inline comments (task 0035)
`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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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")],
|
||||
});
|
||||
}
|
||||
|
||||
55
src/diff.ts
55
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;
|
||||
}
|
||||
|
||||
25
src/flag-file.ts
Normal file
25
src/flag-file.ts
Normal 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
143
src/review-comments.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user