Files
gitea-axi/src/diff.ts
alexion cb801522a9
All checks were successful
CI / test (pull_request) Successful in 57s
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.
2026-07-18 16:06:53 -04:00

129 lines
4.2 KiB
TypeScript

import type { GiteaClient } from "./client.js";
import type { RepoContext } from "./context.js";
import { axiError, classifyHttpError } from "./errors.js";
/** The raw-diff truncation limit, distinct from the body/comment limits. */
export const DIFF_TRUNCATE_LIMIT = 4000;
/**
* A rendered pull-request diff. `truncated`/`original_length` are present only
* when the diff was cut short — signalled as separate fields rather than an
* inline hint, so the diff text itself stays a verbatim (if partial) prefix.
*/
export interface DiffResult {
diff: string;
truncated?: true;
original_length?: number;
}
/**
* Fetch a pull request's raw unified diff from `GET /pulls/{index}.diff`. The
* generated client defaults every response to JSON parsing (its baseApiParams
* set `format: "json"`), which would discard a plain-text diff body — so `text`
* is forced per call to read the response verbatim.
*/
export async function fetchPullDiff(
api: GiteaClient,
context: RepoContext,
number: number,
): Promise<string> {
try {
const response = await api.repos.repoDownloadPullDiffOrPatch(
context.owner,
context.name,
number,
"diff",
undefined,
{ format: "text" },
);
return response.data ?? "";
} catch (error) {
throw classifyHttpError(error);
}
}
/**
* Truncate a diff to {@link DIFF_TRUNCATE_LIMIT}, reporting the original length
* so the caller can offer `--full`. `full` returns the raw diff untouched.
*/
export function truncateDiff(diff: string, full: boolean): DiffResult {
if (full || diff.length <= DIFF_TRUNCATE_LIMIT) {
return { diff };
}
return {
diff: diff.slice(0, DIFF_TRUNCATE_LIMIT),
truncated: true,
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;
}