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

Issues and comments on a repository from another forge read and write through, attributed to the real person #9064

Merged opened this

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

Merged

scout/forge:scout/8967-issues-and-comments-on-a-repository-from → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/8967-issues-and-comments-on-a-repository-from was merged into main as 3fac5a0.

Review

Sign in to review this change.
5 files changed +255 −5
docs/features/forge-origin.md +10 −1
@@ -15,10 +15,19 @@ Type the friend's address at `/settings/origins` and press Continue. If the addr
1515 - **Registration is per callback address:** if this forge's public URL changes, forget the app and connect again.
1616 - Test: `test/forge-origin-oauth.test.ts` runs two forges through the whole flow.
1717
18 Not yet: webhooks and a catch-up for missed events, review and check state coming back live, guest rules for a crew on the receiving side, following upstream with one action.
18 Not yet: review and check state coming back live, guest rules for a crew on the receiving side, following upstream with one action.
1919
2020 Not yet, in order: migrate an app whole with its run and build, follow upstream between forges with a pull back, and the one-page paste-a-link path. Test: `test/forge-origin.test.ts` runs two forges against each other.
2121
22 ## Issues and comments both ways (#8967)
23
24 On a brought repository an issue or comment opened here is written to the origin as the connected account (`src/origins/write-through.ts`), then reflected here under the origin's own number and id.
25
26 - **Webhook:** the origin's issues, comments and pulls arrive by a signed delivery to `/api/v1/repos/:owner/:name/origin-hook` (`src/origins/hook.ts`). It is believed for nothing but "look again": the named issue and its thread are read from the origin (`item-check.ts`; a pull goes to `pull-check.ts` and is never imported as an issue). If the origin cannot be read the delivery is refused (404) so it is sent again.
27 - **Catch-up:** the sync sweep fetches whatever moved since its last clean run, so after the origin was down nothing is lost. Comments are keyed on the origin's id, so a webhook, a sweep and the echo of our own write are one row.
28 - **Registration:** the hook is asked for once, when a mirror is first imported (and after a pull is proposed upstream), and only a forge origin can be asked. A GitHub or Gitea origin relies on the sweep alone. Asking is idempotent (the origin's hooks are listed first, none is added twice).
29 - Test: `test/origin-issues.test.ts` runs two forges through write, webhook and catch-up.
30
2231 ## Both doors, and what was exercised
2332
2433 The terminal reaches the same code as the web and the API: `src/tui/screens/issue.ts` and `pull.ts` comment and change state through `addCommentThrough` and `setIssueStateThrough` (`src/actions/issues.ts`), which call `providers.createComment` / `setState` when the repository is a mirror. The one-click sign-in has no terminal path (a forge has no device grant): Connected origins and Origin apps say so and point to the web page, and the terminal connects a forge with a token.
src/origins/hook.ts +10 −4
@@ -4,9 +4,10 @@
44 * Out: after a pull is proposed there, a forge origin is asked once to send
55 * this mirror's door its events (`watchPulls`). In: the delivery is believed
66 * for nothing but "look again" — it is signed with a secret only this forge
7 * and the origin hold, and it only names a pull; the pull is then read from
8 * the origin as the sync worker would (`pull-check.ts`). The secret is
9 * derived from the forge's key and the repository, so nothing is stored.
7 * and the origin hold, and it only names an issue or a pull, which is then
outdated

This sentence now reads badly: it names an issue or a pull, then says 'the pull is then read'. Reword it.

8 * read from the origin as the sync worker would (`item-check.ts`,
9 * `pull-check.ts`). The secret is derived from the forge's key and the
10 * repository, so nothing is stored.
1011 */
1112
1213 import type { App } from "../app.ts";
@@ -16,6 +17,7 @@ import type { Repo } from "../core/repos.ts";
1617 import { bustOrigin } from "../view/cache.ts";
1718 import { providerForOrigin } from "./credential.ts";
1819 import { HOOK_SIGNATURE_HEADER, hookSecret, hookUrl, signatureOf } from "./hook-secret.ts";
20 import { refreshOriginItem } from "./item-check.ts";
1921 import { refreshOriginPull } from "./pull-check.ts";
2022
2123 export { HOOK_SIGNATURE_HEADER, hookSecret, hookUrl, signatureOf };
@@ -61,7 +63,11 @@ export async function receiveOriginHook(
6163 const number = numberIn(body);
6264 // A delivery naming no pull costs nothing; the sync sweep catches up.

Non-blocking: the comment above this line still says 'no pull'. The delivery now names an issue or a pull, so say that.

6365 if (number === undefined) return true;
64 await refreshOriginPull(ctx, provider.value, link.fullName, number);
66 // The item and its thread first (issue or pull), then a pull's check.
67 const item = await refreshOriginItem(ctx, provider.value, link.fullName, number);
68 // An origin that cannot be read is a refusal, so it sends the event again.
69 if (item === false) return false;
70 if (item === "pull") await refreshOriginPull(ctx, provider.value, link.fullName, number);
6571 bustOrigin(link.origin.id, link.fullName);
6672 return true;
6773 }
src/origins/item-check.ts added +36
@@ -0,0 +1,36 @@
1 /**
2 * An issue, or a comment on one, as its origin currently shows it.
3 *
4 * The origin's webhook (`origins/hook.ts`) names an issue or pull number and
5 * this reads the whole thread again; the sync sweep does the same for what
6 * moved while no delivery came (`sync.ts`). Both land in `importIssue` and
7 * `importComments`, which key on the origin's own ids, so a comment that
8 * arrives twice (the delivery, then the catch-up; or our own write-through,
9 * then its echo) is one row.
10 */
11
12 import type { Database } from "bun:sqlite";
13 import { importComments, importIssue } from "./import.ts";
14 import type { Provider } from "./types.ts";
15
16 /**
17 * Answers whether the origin spoke; `"pull"` when the number is a pull, which
18 * is left alone here (`pull-check.ts` owns pulls, and importing one through
19 * this path would make a stray issue row). An origin that does not speak
20 * leaves the rows as they were.
21 */
22 export async function refreshOriginItem(
outdated

If the number is a pull, this may import it as an issue on origins that serve pulls through their issues endpoint. Guard against that and add a test.

23 ctx: { db: Database; repoId: string; actorId: string },
24 provider: Provider,
25 fullName: string,
26 number: number,
27 ): Promise<boolean | "pull"> {
28 const remote = await provider.getIssue(fullName, number);
29 if (!remote.ok) return false;
30 if (remote.value.pull !== undefined) return "pull";
31 const imported = importIssue(ctx, remote.value);
32 const comments = await provider.listComments(fullName, number);
33 if (!comments.ok) return false;
34 importComments(ctx, imported.issueId, comments.value);
35 return true;
36 }
src/origins/sync.ts +4
@@ -48,6 +48,7 @@ import { bustOrigin } from "../view/cache.ts";
4848 import { aheadOf, forgetAhead, HELD_WORDS } from "./ahead.ts";
4949 import { providerForOrigin } from "./credential.ts";
5050 import { fetchMirror } from "./git-mirror.ts";
51 import { watchPullsSoon } from "./hook.ts";
5152 import {
5253 importComments,
5354 importIssue,
@@ -513,6 +514,9 @@ async function syncMirrorUnguarded(
513514 bustOrigin(origin.id, mirror.fullName);
514515
515516 if (options.initial) {
outdated

Merge this with the if (options.initial) block directly below it. Also make the registration once per mirror.

517 // Once per mirror: ask the origin for its events, so its issues, comments
518 // and pulls arrive at once. The sweep catches up whatever it misses.
519 void watchPullsSoon(app, repo);
516520 // One event for a first import, not one per row: a repo with 900 issues
517521 // would otherwise deliver 900 webhooks the moment it is connected.
518522 app.bus.append({
test/origin-issues.test.ts added +195
@@ -0,0 +1,195 @@
1 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { addCommentThrough, openIssueThrough } from "../src/actions/issues.ts";
6 import { createApp, type App } from "../src/app.ts";
7 import { getOriginById } from "../src/core/origins.ts";
8 import { getRepo } from "../src/core/repos.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { getUserByName } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { hookSecret, HOOK_SIGNATURE_HEADER } from "../src/origins/hook.ts";
13 import { syncOrigin, syncsSettled } from "../src/origins/sync.ts";
14 import { mirrorAndWait } from "./fake-origin.ts";
15 import { seedAdmin } from "./helpers/admin.ts";
16 import { proveAll } from "./helpers/verified.ts";
17
18 /**
19 * Two forges: issues and comments written here land on the origin under the
20 * signed-in person's connected account, the origin's own arrive by signed
21 * webhook, and after the origin was down a sweep fetches what was missed
22 * without a duplicate.
23 */
24
25 setDefaultTimeout(60_000);
26
27 const ALICE = "alice-login-secret";
28 const BOB = "bob-login-secret";
29 const basic = (user: string, secret: string): string =>
30 `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`;
31
32 describe("issues on a repository brought from another forge", () => {
33 const dirs: string[] = [];
34 let mine: App;
35 let friend: App;
36 let mineServers: Servers;
37 let friendServers: Servers;
38 let originId = "";
39
40 const bobApi = (path: string, init: RequestInit = {}) =>
41 fetch(`${friendServers.url}${path}`, {
42 ...init,
43 headers: { authorization: basic("bob", BOB), "content-type": "application/json" },
44 });
45 const mirror = () => getRepo(mine.db, "alice", "app")!;
46 const sync = () => syncOrigin(mine, getOriginById(mine.db, originId)!);
47 const rows = (sql: string, ...args: string[]) => mine.db.query<Record<string, string>, string[]>(sql).all(...args);
48 const bodies = () =>
49 rows("SELECT c.body FROM comment c JOIN issue i ON i.id = c.issue_id WHERE i.repo_id = ?1 ORDER BY c.body", mirror().id).map((r) => r.body);
50 const deliver = async (body: unknown) => {
51 const raw = JSON.stringify(body);
52 const mac = new Bun.CryptoHasher("sha256", hookSecret(mine, mirror())).update(raw).digest("hex");
53 return fetch(`${mineServers.url}/api/v1/repos/alice/app/origin-hook`, {
54 method: "POST",
55 headers: { "content-type": "application/json", [HOOK_SIGNATURE_HEADER]: `sha256=${mac}` },
56 body: raw,
57 });
58 };
59
60 beforeAll(async () => {
61 const make = async () => {
62 const dir = await mkdtemp(join(tmpdir(), "forge-origin-issues-"));
63 dirs.push(dir);
64 return dir;
65 };
66 const probe = Bun.serve({ port: 0, fetch: () => new Response("") });
67 const friendPort = probe.port as number;
68 await probe.stop(true);
69 friend = createApp({ dataDir: await make(), port: friendPort });
70 friendServers = await serve(friend);
71 await seedAdmin(friend, { username: "bob", email: "[email protected]", password: BOB });
72 proveAll(friend);
73 const token = createToken(friend.db, getUserByName(friend.db, "bob")!.id, "alice", "admin").token;
74 await bobApi("/api/v1/repos", { method: "POST", body: JSON.stringify({ name: "app", visibility: "public" }) });
75 await bobApi("/api/v1/repos/bob/app/contents", {
76 method: "POST",
77 body: JSON.stringify({ branch: "main", message: "first", files: [{ path: "README.md", content: "app\n" }] }),
78 });
79
80 mine = createApp({ dataDir: await make(), port: 0, url: "https://mine.test" });
81 mineServers = await serve(mine);
82 await seedAdmin(mine, { username: "alice", email: "[email protected]", password: ALICE });
83 proveAll(mine);
84 const connected = await fetch(`${mineServers.url}/api/v1/user/origins`, {
85 method: "POST",
86 headers: { authorization: basic("alice", ALICE), "content-type": "application/json" },
87 body: JSON.stringify({ host: friendServers.url, token }),
88 });
89 originId = ((await connected.json()) as { id: string }).id;
90 const job = await mirrorAndWait(
91 await fetch(`${mineServers.url}/api/v1/user/origins/${originId}/mirror`, {
92 method: "POST",
93 headers: { authorization: basic("alice", ALICE), "content-type": "application/json" },
94 body: JSON.stringify({ fullName: "bob/app" }),
95 }),
96 );
97 expect(job?.stage).toBe("done");
98 }, 120_000);
99
100 afterAll(async () => {
101 await syncsSettled();
102 mineServers.stop();
103 friendServers.stop();
104 mine.close();
105 friend.close();
106 for (const dir of dirs) await rm(dir, { recursive: true, force: true, maxRetries: 5 });
107 }, 120_000);
108
109 test("an issue and a comment opened here are written to the origin as the connected account", async () => {
110 const alice = getUserByName(mine.db, "alice")!;
111 const opened = await openIssueThrough(mine, alice, mirror(), { title: "It breaks", body: "on start" });
112 if (!opened.ok) throw new Error(opened.error);
113 const there = await (await bobApi(`/api/v1/repos/bob/app/issues/${opened.value.number}`)).json();
114 expect(there).toMatchObject({ title: "It breaks" });
115
116 const posted = await addCommentThrough(mine, alice, mirror(), opened.value, "from here");
117 if (!posted.ok) throw new Error(posted.error);
118 const thread = JSON.stringify(await (await bobApi(`/api/v1/repos/bob/app/issues/${opened.value.number}/comments`)).json());
119 expect(thread).toContain("from here");
120 // Written once locally: the echo of our own write is the same row.
121 expect((await sync()).error).toBe("");
122 expect(bodies().filter((b) => b === "from here")).toHaveLength(1);
123 });
124
125 test("an issue and comment made on the origin arrive by signed webhook", async () => {
126 const made = await bobApi("/api/v1/repos/bob/app/issues", {
127 method: "POST",
128 body: JSON.stringify({ title: "Bob asks" }),
129 });
130 const number = ((await made.json()) as { number: number }).number;
131 await bobApi(`/api/v1/repos/bob/app/issues/${number}/comments`, {
132 method: "POST",
133 body: JSON.stringify({ body: "first from bob" }),
134 });
135 expect(rows("SELECT 1 FROM issue WHERE repo_id = ?1 AND title = 'Bob asks'", mirror().id)).toHaveLength(0);
136
137 const raw = JSON.stringify({ kind: "issue.commented", payload: { number } });
138 const bad = await fetch(`${mineServers.url}/api/v1/repos/alice/app/origin-hook`, {
139 method: "POST",
140 headers: { [HOOK_SIGNATURE_HEADER]: "sha256=00" },
141 body: raw,
142 });
143 expect(bad.status).toBe(404);
144 expect((await deliver({ kind: "issue.commented", payload: { number } })).status).toBe(200);
145 expect(rows("SELECT 1 FROM issue WHERE repo_id = ?1 AND title = 'Bob asks'", mirror().id)).toHaveLength(1);
146 expect(bodies()).toContain("first from bob");
147 // Delivered twice, still one row.
148 await deliver({ kind: "issue.commented", payload: { number } });
149 expect(bodies().filter((b) => b === "first from bob")).toHaveLength(1);
150 });
151
152 test("after the origin was down a sweep fetches what was missed, once", async () => {
153 friendServers.stop();
154 expect((await sync()).error).not.toBe("");
155 friendServers = await serve(friend);
156 const made = await bobApi("/api/v1/repos/bob/app/issues", {
157 method: "POST",
158 body: JSON.stringify({ title: "While you were out" }),
159 });
160 const number = ((await made.json()) as { number: number }).number;
161 await bobApi(`/api/v1/repos/bob/app/issues/${number}/comments`, {
162 method: "POST",
163 body: JSON.stringify({ body: "missed me" }),
164 });
165 await bobApi(`/api/v1/repos/bob/app/issues/1/comments`, {
166 method: "POST",
167 body: JSON.stringify({ body: "late reply" }),
168 });
169 expect((await sync()).error).toBe("");
170 expect((await sync()).error).toBe("");
171 expect(rows("SELECT 1 FROM issue WHERE repo_id = ?1 AND title = 'While you were out'", mirror().id)).toHaveLength(1);
172 expect(bodies().filter((b) => b === "missed me")).toHaveLength(1);
173 expect(bodies().filter((b) => b === "late reply")).toHaveLength(1);
174 });
175 });
176
177 describe("a number that is a pull", () => {
178 test("is never imported as an issue by the item read", async () => {
179 const { Database } = await import("bun:sqlite");
180 const { refreshOriginItem } = await import("../src/origins/item-check.ts");
181 const db = new Database(":memory:");
182 let imported = false;
183 const provider = {
184 getIssue: async () => ({ ok: true, value: { number: 3, pull: {} } }),
185 listComments: async () => {
186 imported = true;
187 return { ok: true, value: [] };
188 },
189 };
190 const ctx = { db, repoId: "r", actorId: "u" };
191 // @ts-expect-error a stub with only what the read touches
192 expect(await refreshOriginItem(ctx, provider, "o/r", 3)).toBe("pull");
193 expect(imported).toBe(false);
194 });
195 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs