Files
gitea-axi/test/pr-review.test.ts
alexion 662ba82d71
All checks were successful
CI / test (pull_request) Successful in 55s
CI / test (push) Successful in 53s
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:11:26 -04:00

310 lines
9.7 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
import { runCliTest, tempFiles, testModeEnv, type TempFiles } from "./harness.js";
const REPO_PATH = "/api/v1/repos/testowner/testrepo";
const REVIEWS_PATH = `${REPO_PATH}/pulls/9/reviews`;
let server: FixtureServer;
const files: TempFiles = tempFiles();
afterEach(async () => {
await server.close();
files.cleanup();
});
function reviewPosted() {
return server.requests.find(
(request) => request.method === "POST" && request.path === REVIEWS_PATH,
);
}
describe("pr review", () => {
it("submits an APPROVED review and reports number and action for --approve", async () => {
server = await startFixtureServer([{ method: "POST", path: REVIEWS_PATH, body: {} }]);
const { stdout, exitCode } = await runCliTest(["pr", "review", "9", "--approve"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(reviewPosted()?.body).toEqual({ event: "APPROVED" });
expect(stdout).toContain("review:");
expect(stdout).toContain("number: 9");
expect(stdout).toContain("action: approve");
});
it.each([
["--request-changes", "REQUEST_CHANGES", "request-changes"],
["--comment", "COMMENT", "comment"],
])("submits %s as event %s and reports action %s", async (flag, event, action) => {
server = await startFixtureServer([{ method: "POST", path: REVIEWS_PATH, body: {} }]);
const { stdout, exitCode } = await runCliTest(["pr", "review", "9", flag], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(reviewPosted()?.body).toEqual({ event });
expect(stdout).toContain("review:");
expect(stdout).toContain("number: 9");
expect(stdout).toContain(`action: ${action}`);
});
it("rejects zero action flags before any API call", async () => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(["pr", "review", "9"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(server.requests).toHaveLength(0);
});
it.each([
["--approve", "--comment"],
["--approve", "--request-changes"],
["--request-changes", "--comment"],
])("rejects multiple action flags (%s %s) before any API call", async (...actions) => {
server = await startFixtureServer([]);
const { stdout, exitCode } = await runCliTest(["pr", "review", "9", ...actions], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(server.requests).toHaveLength(0);
});
it("surfaces a 422 from the server as VALIDATION_ERROR carrying its message", async () => {
server = await startFixtureServer([
{
method: "POST",
path: REVIEWS_PATH,
status: 422,
body: { message: "review body cannot be empty" },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "review", "9", "--request-changes"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(stdout).toContain("review body cannot be empty");
});
it("forwards a --body review body inline", async () => {
server = await startFixtureServer([{ method: "POST", path: REVIEWS_PATH, body: {} }]);
const { exitCode } = await runCliTest(
["pr", "review", "9", "--comment", "--body", "Looks good"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
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");
const { exitCode } = await runCliTest(
["pr", "review", "9", "--request-changes", "--body-file", path],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(reviewPosted()?.body).toEqual({
event: "REQUEST_CHANGES",
body: "Please fix the tests",
});
});
});