Public: anyone can read this; the crew can change it.

A review's line comments travel inside the review to a forge origin, not one by one after it #11483

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11479-a-review-s-line-comments-travel-inside-t → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/11479-a-review-s-line-comments-travel-inside-t was merged into main as 4ac04e2.

Review

Sign in to review this change.
9 files changed +313 −20
docs/ORIGINS.md −4
@@ -1049,10 +1049,6 @@ carries token material, a `state`, or a `code`.
10491049 - **Contributing needs a workbench.** `contribute` mirrors your fork (and, on
10501050 the push-rights path, the upstream) if it is not mirrored already, so the
10511051 first contribution to a large repository pays for a clone and an import.
1052 - **A review's line comments do not travel with its verdict.** Submitting a
1053 review through the origin sends the state and the body; anchored comments
1054 are still posted one at a time. Both forges accept comments inside the
1055 review payload, which is the obvious next step.
10561052 - **The guard's window is a day.** An account that gains push rights to a
10571053 protected repository keeps its credential until the next refresh or the
10581054 next daily sweep, whichever is first.
src/actions/pulls.ts +35 −4
@@ -78,6 +78,7 @@ import { repoDir } from "../git/repos.ts";
7878 import { isStrangerPull, strangerGuard } from "../crew/stranger.ts";
7979 import { importIssue, importReviewStates } from "../origins/import.ts";
8080 import { err, ok, type Result } from "../kernel/result.ts";
81 import { createLog } from "../kernel/log.ts";
8182 import {
8283 refusal,
8384 writeThroughFor,
@@ -97,6 +98,9 @@ import { carryBranchThrough } from "../origins/push-through.ts";
9798 import { ensureFork, forkHead, pushToFork } from "../origins/fork-route.ts";
9899 import { denied } from "../origins/operator.ts";
99100 import type { WriteThrough } from "../origins/write-through.ts";
101 import { requestSyncNow } from "../origins/sync.ts";
102
103 const log = createLog("pulls");
100104
101105 /**
102106 * How a fork of a mirror proposes to the mirror's origin: `direct`, its
@@ -715,7 +719,7 @@ export async function submitReview(
715719 repo: Repo,
716720 pull: Pull,
717721 input: SubmitReviewInput,
718 ): Promise<Result<Review, SubmitReviewActionError>> {
722 ): Promise<Result<Review & { readonly note?: string }, SubmitReviewActionError>> {
719723 if (!isReviewState(input.state)) return err("invalid_state");
720724 const participant = requireParticipant(app, actor, repo);
721725 if (!participant.ok) return participant;
@@ -749,7 +753,16 @@ export async function submitReview(
749753 const sent = await through.value.provider.submitReview(
750754 through.value.fullName,
751755 pull.number,
752 { state: input.state, body: (input.body ?? "").trim() },
756 {
757 state: input.state,
758 body: (input.body ?? "").trim(),
759 comments: anchored.value.map((l) => ({
760 path: l.path,
761 line: l.line,
762 side: l.side,
763 body: l.body,
764 })),
765 },
753766 );
754767 if (!sent.ok) return err(refusal(sent.error.error));
755768 importReviewStates(
@@ -769,8 +782,26 @@ export async function submitReview(
769782 ...(stored ? { reviewId: stored.id } : {}),
770783 },
771784 });
outdated

Untested branch: carried vs refused decides whether lines are posted locally and whether a sync is requested. Add an action-level test that fails if this condition is inverted or removed.

772 // Line comments stay here, as `addReview`'s always have on a mirror.
773 for (const line of anchored.value) postLine(app, pull, user, line);
785 // The origin took the lines inside the review, and sync brings them
786 // back as its own; posting them here too would show each twice. One that
787 // refused the payload got the verdict alone, so the lines stay here, as
788 // `addReview`'s always have on a mirror, and the log says so.
789 if (anchored.value.length > 0 && (sent.value.carried ?? 0) === 0) {
790 log.warn("origin refused the review's line comments; kept here one by one", {
791 repo: repo.id,
792 number: pull.number,
793 lines: anchored.value.length,
794 });
795 for (const line of anchored.value) postLine(app, pull, user, line);
796 return stored
797 ? ok({
798 ...stored,
799 note: "The origin refused line comments inside a review: the verdict went there alone and the line comments are kept here.",
800 })
801 : err("origin_refused");
802 } else if (anchored.value.length > 0) {
803 requestSyncNow(app, through.value.origin.id);
804 }
774805 return stored ? ok(stored) : err("origin_refused");
775806 }
776807
src/origins/providers/forgejo.ts +17 −6
@@ -45,6 +45,7 @@ import type {
4545 ArchivedPage,
4646 ArchivedRepo,
4747 RemoteReviewState,
48 ReviewLine,
4849 RemoteTreeEntry,
4950 RepoWalk,
5051 } from "../types.ts";
@@ -76,6 +77,7 @@ import {
7677 labelNames,
7778 linkNext,
7879 pathSegments,
80 postReview,
7981 readmeEntry,
8082 seg,
8183 sortEntries,
@@ -706,6 +708,7 @@ export class ForgejoProvider implements Provider {
706708 input: {
707709 state: "approved" | "changes_requested" | "commented";
708710 body: string;
711 comments?: readonly ReviewLine[];
709712 },
710713 ): Promise<ProviderResult<RemoteReviewState>> {
711714 const named = splitFullName(fullName);
@@ -718,16 +721,24 @@ export class ForgejoProvider implements Provider {
718721 : input.state === "changes_requested"
719722 ? "REQUEST_CHANGES"
720723 : "COMMENT";
721 const result = await this.api.call({
722 path: `/repos/${seg(named.owner)}/${seg(named.name)}/pulls/${number}/reviews`,
723 method: "POST",
724 body: { event, body: input.body },
725 });
724 const lines = (input.comments ?? []).map((l) => ({
725 path: l.path,
726 body: l.body,
727 ...(l.side === "old"
728 ? { old_position: l.line }
729 : { new_position: l.line }),
730 }));
731 const result = await postReview(
732 this.api,
733 `/repos/${seg(named.owner)}/${seg(named.name)}/pulls/${number}/reviews`,
734 { event, body: input.body },
735 lines,
736 );
726737 if (!result.ok) return result;
727738 const state = toReviewState(result.value.data);
728739 return state === undefined
729740 ? fail("invalid_response", NO_REVIEW)
730 : succeed(state);
741 : succeed({ ...state, carried: result.value.carried });
731742 }
732743
733744 async createIssue(
src/origins/providers/github.ts +16 −6
@@ -29,6 +29,7 @@ import type {
2929 RemoteReview,
3030 RemoteMilestone,
3131 RemoteReviewState,
32 ReviewLine,
3233 RemoteTreeEntry,
3334 TokenReach,
3435 RepoWalk,
@@ -62,6 +63,7 @@ import {
6263 labelNames,
6364 linkNext,
6465 pathSegments,
66 postReview,
6567 seg,
6668 sortEntries,
6769 splitFullName,
@@ -611,6 +613,7 @@ export class GitHubProvider implements Provider {
611613 input: {
612614 state: "approved" | "changes_requested" | "commented";
613615 body: string;
616 comments?: readonly ReviewLine[];
614617 },
615618 ): Promise<ProviderResult<RemoteReviewState>> {
616619 const named = splitFullName(fullName);
@@ -623,16 +626,23 @@ export class GitHubProvider implements Provider {
623626 : input.state === "changes_requested"
624627 ? "REQUEST_CHANGES"
625628 : "COMMENT";
626 const result = await this.api.call({
627 path: `/repos/${seg(named.owner)}/${seg(named.name)}/pulls/${number}/reviews`,
628 method: "POST",
629 body: { event, body: input.body },
630 });
629 const lines = (input.comments ?? []).map((l) => ({
630 path: l.path,
631 line: l.line,
632 side: l.side === "old" ? "LEFT" : "RIGHT",
633 body: l.body,
634 }));
635 const result = await postReview(
636 this.api,
637 `/repos/${seg(named.owner)}/${seg(named.name)}/pulls/${number}/reviews`,
638 { event, body: input.body },
639 lines,
640 );
631641 if (!result.ok) return result;
632642 const state = toReviewState(result.value.data);
633643 return state === undefined
634644 ? fail("invalid_response", NO_REVIEW)
635 : succeed(state);
645 : succeed({ ...state, carried: result.value.carried });
636646 }
637647
638648 async createIssue(
src/origins/providers/http.ts +26
@@ -788,3 +788,29 @@ export function readmeEntry(
788788 }
789789 return files.find((e) => e.name.toLowerCase().startsWith("readme."));
790790 }
791
792 /**
793 * Post a review with its line comments inside it, in one call. An origin
794 * that refuses the payload (a 4xx that is not about who we are, nor about
795 * the pull's existence) is asked again with the verdict alone, so a shape
796 * it dislikes never costs the verdict: `carried` says which one it took.
797 */
798 export async function postReview(
799 api: ApiClient,
800 path: string,
801 review: { event: string; body: string },
802 lines: readonly Record<string, unknown>[],
803 ): Promise<ProviderResult<{ data: unknown; carried: number }>> {
804 if (lines.length > 0) {
805 const whole = await api.call({
806 path,
807 method: "POST",
808 body: { ...review, comments: lines },
809 });
810 if (whole.ok) return succeed({ data: whole.value.data, carried: lines.length });
811 if (whole.error.error !== "invalid_response") return whole;
812 }
813 const bare = await api.call({ path, method: "POST", body: review });
814 if (!bare.ok) return bare;
815 return succeed({ data: bare.value.data, carried: 0 });
816 }
src/origins/types.ts +12
@@ -174,6 +174,16 @@ export interface RemoteReviewState {
174174 readonly commitOid: string;
175175 readonly createdAt: string;
176176 readonly htmlUrl: string;
177 /** How many line comments the origin took inside this review (`submitReview` only). */
178 readonly carried?: number;
179 }
180
181 /** A line comment bound for an origin, inside the review that carries it. */
182 export interface ReviewLine {
183 readonly path: string;
184 readonly line: number;
185 readonly side: "old" | "new";
186 readonly body: string;
177187 }
178188
179189 /** How a pull's check stands on its origin, for the head named. */
@@ -511,6 +521,8 @@ export interface Provider {
511521 input: {
512522 state: "approved" | "changes_requested" | "commented";
513523 body: string;
524 /** Sent inside the review; an origin that refuses them gets the verdict alone. */
525 comments?: readonly ReviewLine[];
514526 },
515527 ): Promise<ProviderResult<RemoteReviewState>>;
516528 /**
test/fake-origin.ts +4
@@ -203,6 +203,8 @@ export interface FakeState {
203203 conditionalHits: number;
204204 /** Refuse the next write with this message. */
205205 refuseWrite: string;
206 /** Refuse (422) a review posted with `comments` inside it; a bare verdict goes through. */
207 refuseReviewComments?: boolean;
206208 /** Forgejo webhooks as created, keyed `owner/name`. */
207209 hooks: Map<string, Record<string, unknown>[]>;
208210 /**
@@ -904,6 +906,8 @@ function route(
904906 if (suffix === "/reviews" && method === "POST") {
905907 if (state.refuseWrite !== "") return refuse(state);
906908 const input = parse(bodyText);
909 if (state.refuseReviewComments && Array.isArray(input.comments))
910 return jsonResponse({ message: "comments: unexpected" }, 422);
907911 const created: FakeReviewState = {
908912 id: state.nextId++,
909913 issueNumber: number,
test/origin-review-actions.test.ts added +123
@@ -0,0 +1,123 @@
1 /**
2 * Submitting a review with line comments on a mirrored pull (#11479): when
3 * the origin carries the lines inside the review they are not also posted
4 * here, and a sync is asked for; when it refuses them they are posted here,
5 * once, and the reviewer is told.
6 */
7 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { submitReview } from "../src/actions/pulls.ts";
13 import { getCursor } from "../src/core/origins.ts";
14 import { listPulls } from "../src/core/pulls.ts";
15 import { getRepo } from "../src/core/repos.ts";
16 import { getUserByName } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { syncsSettled } from "../src/origins/sync.ts";
19 import { mirrorAndWait, newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
20 import { seedAdmin } from "./helpers/admin.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22 import { proveAll } from "./helpers/verified.ts";
23
24 quietLogs();
25 setDefaultTimeout(60_000);
26
27 const LOGIN = "octo";
28 const TOKEN = "octo-review-lines-secret-3a9e";
29 const PASSWORD = "alice-login-secret";
30 const basic = (u: string, p: string) => `Basic ${Buffer.from(`${u}:${p}`).toString("base64")}`;
31
32 describe("a review's lines on a mirrored pull", () => {
33 const dirs: string[] = [];
34 let app: App;
35 let servers: Servers;
36 let originApp: App;
37 let originServers: Servers;
38 let fake: FakeForge;
39 let originId = "";
40
41 const api = (path: string, init?: RequestInit) => {
42 const headers = new Headers(init?.headers);
43 headers.set("content-type", "application/json");
44 headers.set("authorization", basic("alice", PASSWORD));
45 return fetch(`${servers.url}${path}`, { ...init, headers });
46 };
47 const repo = () => getRepo(app.db, "alice", "vault")!;
48 const pull = () => listPulls(app.db, repo().id)[0]!;
49 const posts = () => fake.state.requests.filter((r) => r.method === "POST" && r.path.endsWith("/pulls/1/reviews"));
50 const localLines = () =>
51 app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM comment WHERE issue_id = ?1 AND path IS NOT NULL").get(pull().id)!.n;
52 const nextSync = () => getCursor(app.db, originId, "next_sync_at");
53 const lines = [{ path: "b.txt", line: 1, side: "new" as const, body: "why this?" }];
54
55 beforeAll(async () => {
56 for (const p of ["forge-rl-", "forge-rl-up-"]) dirs.push(await mkdtemp(join(tmpdir(), p)));
57 originApp = createApp({ dataDir: dirs[1]!, port: 0 });
58 originServers = await serve(originApp);
59 await seedAdmin(originApp, { username: LOGIN, email: "[email protected]", password: TOKEN });
60 const oh = { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) };
61 await fetch(`${originServers.url}/api/v1/repos`, { method: "POST", headers: oh, body: JSON.stringify({ name: "vault", visibility: "public" }) });
62 await fetch(`${originServers.url}/api/v1/repos/octo/vault/contents`, {
63 method: "POST", headers: oh,
64 body: JSON.stringify({ branch: "main", message: "first", files: [{ path: "a.txt", content: "one\n" }] }),
65 });
66 const made = await fetch(`${originServers.url}/api/v1/repos/octo/vault/contents`, {
67 method: "POST", headers: oh,
68 body: JSON.stringify({ branch: "feature", message: "more", files: [{ path: "b.txt", content: "two\n" }] }),
69 });
70 const { commitOid } = (await made.json()) as { commitOid: string };
71
72 const state = newFakeState("github", LOGIN, TOKEN);
73 state.repos.push({ id: 9, fullName: "octo/vault", cloneUrl: `${originServers.url}/octo/vault.git`, defaultBranch: "main", htmlUrl: `${originServers.url}/octo/vault`, push: true });
74 state.issues.set("octo/vault", [
75 { id: 5, number: 1, title: "more", body: "", state: "open", user: LOGIN, labels: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", pull: { base: "main", head: "feature", headSha: commitOid } },
76 ] as never);
77 fake = serveFakeForge(state);
78
79 app = createApp({ dataDir: dirs[0]!, port: 0 });
80 servers = await serve(app);
81 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: PASSWORD });
82 if (!admin.ok) throw new Error(admin.error);
83 const connected = (await (await api("/api/v1/user/origins", { method: "POST", body: JSON.stringify({ host: fake.url, kind: "github", token: TOKEN }) })).json()) as { id: string };
84 originId = connected.id;
85 await mirrorAndWait(await api(`/api/v1/user/origins/${originId}/mirror`, { method: "POST", body: JSON.stringify({ fullName: "octo/vault" }) }));
86 await syncsSettled();
87 proveAll(app);
88 });
89
90 afterAll(async () => {
91 await syncsSettled();
92 fake.stop();
93 servers.stop();
94 originServers.stop();
95 app.close();
96 originApp.close();
97 for (const d of dirs) await rm(d, { recursive: true, force: true });
98 });
99
100 const send = () =>
101 submitReview(app, getUserByName(app.db, "alice")!, repo(), pull(), { state: "changes_requested", body: "see lines", comments: lines });
102
103 test("carried by the origin: one request, no local duplicate, a sync asked for", async () => {
104 expect(pull().number).toBe(1);
105 app.db.query("DELETE FROM origin_cursor WHERE origin_id = ?1 AND key = 'next_sync_at'").run(originId);
106 const sent = await send();
107 expect(sent.ok).toBe(true);
108 expect(posts()).toHaveLength(1);
109 expect(localLines()).toBe(0);
110 expect(nextSync()).toBe(new Date(0).toISOString());
111 if (sent.ok) expect(sent.value.note).toBeUndefined();
112 });
113
114 test("refused by the origin: the lines are posted here once, and the reviewer is told", async () => {
115 fake.state.refuseReviewComments = true;
116 const before = posts().length;
117 const sent = await send();
118 expect(sent.ok).toBe(true);
119 expect(posts().length - before).toBe(2);
120 expect(localLines()).toBe(1);
121 if (sent.ok) expect(sent.value.note).toContain("kept here");
122 });
123 });
test/origin-review-comments.test.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * A review's line comments travel inside the review (#11479): one request
3 * carries the verdict and the comments, and an origin that refuses that
4 * payload still gets the verdict, with the comments posted the old way.
5 */
6
7 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8 import { ForgejoProvider } from "../src/origins/providers/forgejo.ts";
9 import { GitHubProvider } from "../src/origins/providers/github.ts";
10 import { staticCredential, type ReviewLine } from "../src/origins/types.ts";
11 import { newFakeState, serveFakeForge, type FakeForge, type FakeState } from "./fake-origin.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 const TOKEN = "tok-review-lines-1";
17 const lines: ReviewLine[] = [
18 { path: "a.ts", line: 3, side: "new", body: "one" },
19 { path: "a.ts", line: 9, side: "old", body: "two" },
20 { path: "b.ts", line: 1, side: "new", body: "three" },
21 ];
22
23 for (const kind of ["forgejo", "github"] as const) {
24 describe(`${kind}: line comments inside the review`, () => {
25 let state: FakeState;
26 let fake: FakeForge;
27 beforeEach(() => {
28 state = newFakeState(kind, "trevato", TOKEN);
29 state.repos.push({ id: 1, fullName: "o/r", cloneUrl: "http://127.0.0.1/none.git", admin: true });
30 state.issues.set("o/r", [
31 { id: 5, number: 1, title: "p", body: "", state: "open", user: "x", labels: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", pull: { headSha: "a".repeat(40), headRef: "h", baseRef: "main", merged: false } },
32 ] as never);
33 fake = serveFakeForge(state);
34 });
35 afterEach(() => fake.stop());
36 const provider = () => {
37 const credential = staticCredential({ kind, apiUrl: fake.apiUrl, webUrl: fake.url, token: TOKEN });
38 return kind === "forgejo" ? new ForgejoProvider(credential) : new GitHubProvider(credential);
39 };
40 const posts = () => state.requests.filter((r) => r.method === "POST" && r.path.endsWith("/pulls/1/reviews"));
41
42 test("one request carries the verdict and the three comments", async () => {
43 const sent = await provider().submitReview("o/r", 1, { state: "changes_requested", body: "see lines", comments: lines });
44 expect(sent.ok).toBe(true);
45 if (sent.ok) expect(sent.value.carried).toBe(3);
46 expect(posts()).toHaveLength(1);
47 const body = JSON.parse(posts()[0]!.body);
48 expect(body.event).toBe("REQUEST_CHANGES");
49 expect(body.comments).toHaveLength(3);
50 expect(body.comments[0]).toMatchObject(
51 kind === "forgejo" ? { path: "a.ts", new_position: 3, body: "one" } : { path: "a.ts", line: 3, side: "RIGHT", body: "one" },
52 );
53 expect(body.comments[1]).toMatchObject(kind === "forgejo" ? { old_position: 9 } : { line: 9, side: "LEFT" });
54 });
55
56 test("a review without lines sends no comments key", async () => {
57 await provider().submitReview("o/r", 1, { state: "approved", body: "" });
58 expect(posts()).toHaveLength(1);
59 expect("comments" in JSON.parse(posts()[0]!.body)).toBe(false);
60 });
61
62 test("an origin that refuses the payload still gets the verdict, alone", async () => {
63 state.refuseReviewComments = true;
64 const sent = await provider().submitReview("o/r", 1, { state: "changes_requested", body: "ok", comments: lines });
65 expect(sent.ok).toBe(true);
66 if (sent.ok) expect(sent.value.carried).toBe(0);
67 const bodies = posts().map((r) => JSON.parse(r.body));
68 expect(bodies).toHaveLength(2);
69 expect("comments" in bodies[1]).toBe(false);
70 expect(bodies[1].body).toBe("ok");
71 });
72
73 test("a refusal about who we are is not retried", async () => {
74 state.token = "other";
75 const sent = await provider().submitReview("o/r", 1, { state: "approved", body: "", comments: lines });
76 expect(sent.ok).toBe(false);
77 expect(posts()).toHaveLength(1);
78 });
79 });
80 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs