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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8967-issues-and-comments-on-a-repository-from → main 3 commits fork 5 threads · 5 unresolved
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 | ||
| 15 | 15 | - **Registration is per callback address:** if this forge's public URL changes, forget the app and connect again. |
| 16 | 16 | - Test: `test/forge-origin-oauth.test.ts` runs two forges through the whole flow. |
| 17 | 17 | |
| 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. | |
| 19 | 19 | |
| 20 | 20 | 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. |
| 21 | 21 | |
| 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 | ||
| 22 | 31 | ## Both doors, and what was exercised |
| 23 | 32 | |
| 24 | 33 | 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 @@ | ||
| 4 | 4 | * Out: after a pull is proposed there, a forge origin is asked once to send |
| 5 | 5 | * this mirror's door its events (`watchPulls`). In: the delivery is believed |
| 6 | 6 | * 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 | |
| 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. | |
| 10 | 11 | */ |
| 11 | 12 | |
| 12 | 13 | import type { App } from "../app.ts"; |
| @@ -16,6 +17,7 @@ import type { Repo } from "../core/repos.ts"; | ||
| 16 | 17 | import { bustOrigin } from "../view/cache.ts"; |
| 17 | 18 | import { providerForOrigin } from "./credential.ts"; |
| 18 | 19 | import { HOOK_SIGNATURE_HEADER, hookSecret, hookUrl, signatureOf } from "./hook-secret.ts"; |
| 20 | import { refreshOriginItem } from "./item-check.ts"; | |
| 19 | 21 | import { refreshOriginPull } from "./pull-check.ts"; |
| 20 | 22 | |
| 21 | 23 | export { HOOK_SIGNATURE_HEADER, hookSecret, hookUrl, signatureOf }; |
| @@ -61,7 +63,11 @@ export async function receiveOriginHook( | ||
| 61 | 63 | const number = numberIn(body); |
| 62 | 64 | // 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. |
||
| 63 | 65 | 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); | |
| 65 | 71 | bustOrigin(link.origin.id, link.fullName); |
| 66 | 72 | return true; |
| 67 | 73 | } |
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"; | ||
| 48 | 48 | import { aheadOf, forgetAhead, HELD_WORDS } from "./ahead.ts"; |
| 49 | 49 | import { providerForOrigin } from "./credential.ts"; |
| 50 | 50 | import { fetchMirror } from "./git-mirror.ts"; |
| 51 | import { watchPullsSoon } from "./hook.ts"; | |
| 51 | 52 | import { |
| 52 | 53 | importComments, |
| 53 | 54 | importIssue, |
| @@ -513,6 +514,9 @@ async function syncMirrorUnguarded( | ||
| 513 | 514 | bustOrigin(origin.id, mirror.fullName); |
| 514 | 515 | |
| 515 | 516 | if (options.initial) { |
outdated
Merge this with the |
||
| 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); | |
| 516 | 520 | // One event for a first import, not one per row: a repo with 900 issues |
| 517 | 521 | // would otherwise deliver 900 webhooks the moment it is connected. |
| 518 | 522 | 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 | }); |
new above
offline
This sentence now reads badly: it names an issue or a pull, then says 'the pull is then read'. Reword it.