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

Work crosses forges: pulls, issues and reviews flow both ways, and each side's crew sees them #8921

Merged opened this in answer to

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

Merged

mason/forge:mason/8903-work-crosses-forges-pulls-issues-and-rev → main 3 commits fork

  • Merged. mason/8903-work-crosses-forges-pulls-issues-and-rev was merged into main as ba3f4cb.

Review

Sign in to review this change.
5 files changed +406 −12
docs/features/forge-origin.md +13 −2
@@ -4,6 +4,17 @@ Connect another forge at `/settings/origins` the way you connect Forgejo: type i
44
55 - **Picker:** `GET /api/v1/user/repos` lists the token's account's repositories, private ones included, in the Gitea shape. Only that route speaks the Gitea shape; a repository's own JSON is unchanged.
66 - **Credential:** the token travels as `Authorization: token …` for the API (accepted as a Bearer token) and as Basic through `GIT_CONFIG_*` for git — never argv, never config.
7 - **Mirror:** brings the code and its history. The origin's card and `GET /api/v1/user/origins` (`carries`) say what did not come: issues, pulls, comments and labels (not yet), how the app runs (not yet), and its data and secrets (never by default).
7 - **Mirror:** brings the code and its history, and the issues, pulls and comments, read in the forge's own JSON (`src/origins/providers/forge-work.ts`) with the real author kept. The origin's card and `GET /api/v1/user/origins` (`carries`) say what did not come: labels and how the app runs (not yet), and its data and secrets (never by default).
8 - **Write-through:** a comment, an issue or a state change here goes to the other forge first, with the connecting person's token (`rw` needed), and is refused with nothing changed here when the forge is down or says no. A pull is opened there with `createPull` (the contribute path); its merge comes back with the next sync.
89
9 Not in this piece, in order: sign in through OAuth, 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.
10 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.
11
12 Not in the first piece, in order: sign in through OAuth, 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.
13
14 ## Both doors, and what was exercised
15
16 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. No terminal screen or entry changed, so the write-through tests cover it; there is no separate terminal path to test.
17
18 A forge's comments carry no edit time, so `updatedAt` is read when the forge sends one and is the creation time otherwise: an edited comment syncs once the forge reports edits. A thread is paged to its end; one of more than 100,000 comments is a failed sync, never a short one.
19
20 The preview of the pull was not exercised: the write-through is proven by `test/forge-origin.test.ts` (two forges in one process, including the origin going down and coming back), not by hand on the preview.
src/origins/providers/forge-work.ts added +78
@@ -0,0 +1,78 @@
1 /**
2 * The work on another forge, read and written in that forge's own JSON.
3 *
4 * A forge's issues, pulls and comments are not Gitea's: the author is
5 * `authorName` (or `originAuthor` when the thread was itself brought across),
6 * labels are `{name}`, a list pages by `next_cursor` and a thread by `next`.
7 * Plain data in, plain data out: these turn one such value into the shape
8 * every provider returns, and `ForgeProvider` does the calling.
9 */
10
11 import type { RemoteComment, RemoteIssue } from "../types.ts";
12 import { asId, asNumber, asState, asString, asTime, field, labelNames } from "./http.ts";
13
14 const authorOf = (raw: unknown): string =>
15 asString(field(raw, "originAuthor")) || asString(field(raw, "authorName"));
16
17 /** An issue, or a pull when it carries branches. */
18 export function toForgeWork(raw: unknown, webUrl: string, fullName: string): RemoteIssue {
19 const created = asTime(field(raw, "createdAt")) ?? new Date().toISOString();
20 const number = asNumber(field(raw, "number"));
21 const base = asString(field(raw, "baseBranch"));
22 const issue: RemoteIssue = {
23 remoteId: asId(field(raw, "id")),
24 number,
25 title: asString(field(raw, "title")),
26 body: asString(field(raw, "body")),
27 author: authorOf(raw),
28 state: asState(field(raw, "state")),
29 labels: labelNames(field(raw, "labels")),
30 createdAt: created,
31 updatedAt: asTime(field(raw, "updatedAt")) ?? created,
32 closedAt: asTime(field(raw, "closedAt")),
33 htmlUrl: `${webUrl}/${fullName}/${base === "" ? "issues" : "pulls"}/${number}`,
34 comments: asNumber(field(raw, "comments")),
35 };
36 if (base === "") return issue;
37 return {
38 ...issue,
39 pull: {
40 baseBranch: base,
41 headBranch: asString(field(raw, "headBranch")),
42 headRepo: asString(field(raw, "originHeadRepo")) || fullName,
43 baseOid: asString(field(raw, "baseOid")),
44 headOid: asString(field(raw, "headOid")),
45 mergedAt: asTime(field(raw, "mergedAt")),
46 draft: false,
47 },
48 };
49 }
50
51 /**
52 * A comment, linked to its thread (a pull answers at `issues/N` too) at the
53 * anchor the thread page gives it. A forge's comments carry no edit time of
54 * their own; `updatedAt` is read when one does, else the creation time.
55 */
56 export function toForgeComment(
57 raw: unknown,
58 webUrl: string,
59 fullName: string,
60 number: number,
61 ): RemoteComment {
62 const created = asTime(field(raw, "createdAt")) ?? new Date().toISOString();
63 const id = asId(field(raw, "id"));
64 return {
65 remoteId: id,
66 author: authorOf(raw),
67 body: asString(field(raw, "body")),
68 createdAt: created,
69 updatedAt: asTime(field(raw, "updatedAt")) ?? created,
70 htmlUrl: `${webUrl}/${fullName}/issues/${number}#c-${id}`,
71 };
72 }
73
74 /** The cursor a list hands back, as the string the next call sends. */
75 export function forgeCursor(raw: unknown): string | null {
76 const next = field(raw, "next_cursor") ?? field(raw, "next");
77 return typeof next === "number" || (typeof next === "string" && next !== "") ? String(next) : null;
78 }
src/origins/providers/forge.ts +150 −6
@@ -4,14 +4,15 @@
44 * A forge answers the Gitea dialect only for the one list connecting needs,
55 * `GET /user/repos`; who you are and one repository are read in its own
66 * shape, so the rest of its API grows no Gitea fields. Git comes over HTTPS
7 * as ever. What a forge does not yet answer to an origin is its issues,
8 * pulls, comments and labels: those reads say "none" rather than failing, so
9 * the mirror brings the code across and settles, and the origin's `carries`
10 * note says what did not come.
7 * as ever. Issues, pulls and comments are read and written in its own JSON
8 * (`forge-work.ts`); labels do not come yet and say "none" rather than
9 * failing, and the origin's `carries` note says so.
1110 */
1211
1312 import type {
13 ListIssuesQuery,
1414 Page,
15 RemoteComment,
1516 ProviderResult,
1617 RemoteIdentity,
1718 RemoteLabel,
@@ -21,8 +22,10 @@ import type {
2122 } from "../types.ts";
2223 import { ForgejoProvider, nextPage, toRepo } from "./forgejo.ts";
2324 import { NO_ACCOUNT } from "../words.ts";
25 import { forgeCursor, toForgeComment, toForgeWork } from "./forge-work.ts";
2426 import { asArray, asString, dig, fail, field, seg, splitFullName, succeed } from "./http.ts";
2527
28 const MAX_COMMENT_PAGES = 1000;
2629 const NONE = { ok: true, value: [] } as const;
2730
2831 export class ForgeProvider extends ForgejoProvider {
@@ -89,7 +92,148 @@ export class ForgeProvider extends ForgejoProvider {
8992 return Promise.resolve(NONE);
9093 }
9194
92 override listIssues(): Promise<ProviderResult<Page<RemoteIssue>>> {
93 return Promise.resolve({ ok: true, value: { items: [], next: null } });
95 private repoPath(fullName: string): string | undefined {
96 const named = splitFullName(fullName);
97 return named ? `/repos/${seg(named.owner)}/${seg(named.name)}` : undefined;
98 }
99
100 /** Issues or pulls, newest first, a page at a time. */
101 override async listIssues(
102 fullName: string,
103 query: ListIssuesQuery,
104 ): Promise<ProviderResult<Page<RemoteIssue>>> {
105 const base = this.repoPath(fullName);
106 if (base === undefined) return fail("not_found", `not a repository name: ${fullName}`);
107 const limit = await this.pageSize();
108 const result = await this.api.call({
109 path: `${base}/${query.kind === "pull" ? "pulls" : "issues"}`,
110 query: {
111 state: query.state,
112 limit,
113 ...(query.cursor === undefined ? {} : { cursor: query.cursor }),
114 },
115 });
116 if (!result.ok) return result;
117 const rows = asArray(field(result.value.data, "items")).filter(
118 (raw) => raw !== null && typeof raw === "object",
119 );
120 const items = rows
121 .map((raw) => toForgeWork(raw, this.webUrl, fullName))
122 .filter((i) => query.since === undefined || i.updatedAt >= query.since);
123 return succeed({ items, next: forgeCursor(result.value.data) });
124 }
125
126 override async getIssue(fullName: string, number: number): Promise<ProviderResult<RemoteIssue>> {
127 const base = this.repoPath(fullName);
128 if (base === undefined) return fail("not_found", `not a repository name: ${fullName}`);
129 let result = await this.api.call({ path: `${base}/issues/${number}` });
130 // A pull answers at `/issues` too, but only `/pulls` carries its branches and merge.
131 if (result.ok && asString(field(result.value.data, "kind")) === "pull")
132 result = await this.api.call({ path: `${base}/pulls/${number}`, query: { diff: 0 } });
133 if (!result.ok) return result;
134 return succeed(toForgeWork(result.value.data, this.webUrl, fullName));
135 }
136
137 override async listComments(
138 fullName: string,
139 number: number,
140 since?: string,
141 ): Promise<ProviderResult<readonly RemoteComment[]>> {
142 const base = this.repoPath(fullName);
143 if (base === undefined) return fail("not_found", `not a repository name: ${fullName}`);
144 const comments: RemoteComment[] = [];
145 let after: string | undefined;
146 // A thread comes oldest first, `next` the id to continue after. Paging
147 // stops at the end of the thread; a thread that never ends is a failure,
148 // not a short list.
149 for (let page = 0; ; page++) {
150 if (page >= MAX_COMMENT_PAGES)
151 return fail("invalid_response", `thread ${number} has more than ${MAX_COMMENT_PAGES * 100} comments`);
152 const result = await this.api.call({
153 path: `${base}/issues/${number}/comments`,
154 query: { limit: 100, ...(after === undefined ? {} : { after }) },
155 });
156 if (!result.ok) return result;
157 for (const raw of asArray(field(result.value.data, "comments")))
158 comments.push(toForgeComment(raw, this.webUrl, fullName, number));
159 after = forgeCursor(result.value.data) ?? undefined;
160 if (after === undefined) break;
161 }
162 return succeed(since === undefined ? comments : comments.filter((c) => c.updatedAt >= since));
163 }
164
165 override async createIssue(
166 fullName: string,
167 input: { title: string; body: string; labels?: readonly string[] },
168 ): Promise<ProviderResult<RemoteIssue>> {
169 const base = this.repoPath(fullName);
170 if (base === undefined) return fail("not_found", `not a repository name: ${fullName}`);
171 const result = await this.api.call({
172 path: `${base}/issues`,
173 method: "POST",
174 body: { title: input.title, body: input.body },
175 });
176 if (!result.ok) return result;
177 return succeed(toForgeWork(result.value.data, this.webUrl, fullName));
178 }
179
180 override async createComment(
181 fullName: string,
182 number: number,
183 body: string,
184 ): Promise<ProviderResult<RemoteComment>> {
185 const base = this.repoPath(fullName);
186 if (base === undefined) return fail("not_found", `not a repository name: ${fullName}`);
187 const result = await this.api.call({
188 path: `${base}/issues/${number}/comments`,
189 method: "POST",
190 body: { body },
191 });
192 if (!result.ok) return result;
193 return succeed(toForgeComment(result.value.data, this.webUrl, fullName, number));
194 }
195
196 override async createPull(
197 fullName: string,
198 input: { title: string; body: string; base: string; head: string },
199 ): Promise<ProviderResult<RemoteIssue>> {
200 const named = splitFullName(fullName);
201 if (!named) return fail("not_found", `not a repository name: ${fullName}`);
202 const path = `/repos/${seg(named.owner)}/${seg(named.name)}`;
203 // A fork's branch is `owner:branch`; a forge names it as head plus headRepo.
204 const parts = input.head.split(":");
205 if (parts.length > 2 || parts.some((p) => p === ""))
206 return fail("not_found", `not a branch: ${input.head}`);
207 const [owner, branch] = parts.length === 2 ? parts : [undefined, parts[0]];
208 const name = named.name;
209 const result = await this.api.call({
210 path: `${path}/pulls`,
211 method: "POST",
212 body: {
213 title: input.title,
214 body: input.body,
215 base: input.base,
216 head: branch,
217 ...(owner === undefined ? {} : { headRepo: `${owner}/${name}` }),
218 },
219 });
220 if (!result.ok) return result;
221 return succeed(toForgeWork(result.value.data, this.webUrl, fullName));
222 }
223
224 override async setState(
225 fullName: string,
226 number: number,
227 state: "open" | "closed",
228 ): Promise<ProviderResult<RemoteIssue>> {
229 const base = this.repoPath(fullName);
230 if (base === undefined) return fail("not_found", `not a repository name: ${fullName}`);
231 const result = await this.api.call({
232 path: `${base}/issues/${number}`,
233 method: "PATCH",
234 body: { state },
235 });
236 if (!result.ok) return result;
237 return succeed(toForgeWork(result.value.data, this.webUrl, fullName));
94238 }
95239 }
src/origins/words.ts +1 −1
@@ -24,4 +24,4 @@ export const PATH_IS_DIRECTORY = "that path is a directory, not a file";
2424 * and in the API, so a mirror of a friend's app never reads as the whole of it.
2525 */
2626 export const FORGE_NOT_CARRIED =
27 "Code comes across, and the repository's history. Not yet: issues, pulls, comments, labels, and how the app runs. Never by default: its data and its secrets.";
27 "Code comes across, and the repository's history. Issues, pulls and comments read and write through. Not yet: labels, and how the app runs. Never by default: its data and its secrets.";
test/forge-origin.test.ts +164 −3
@@ -5,11 +5,16 @@ import { join } from "node:path";
55 import { createApp, type App } from "../src/app.ts";
66 import { createToken } from "../src/core/tokens.ts";
77 import { getUserByName } from "../src/core/users.ts";
8 import { getOriginById } from "../src/core/origins.ts";
9 import { getPull } from "../src/core/pulls.ts";
10 import { getRepo } from "../src/core/repos.ts";
11 import { listComments, listIssues } from "../src/core/issues.ts";
812 import { discover } from "../src/origins/discover.ts";
13 import { providerForOrigin } from "../src/origins/credential.ts";
914 import { resolveOid } from "../src/git/merge.ts";
1015 import { repoDir } from "../src/git/repos.ts";
1116 import { serve, type Servers } from "../src/http/server.ts";
12 import { syncsSettled } from "../src/origins/sync.ts";
17 import { syncOrigin, syncsSettled } from "../src/origins/sync.ts";
1318 import { mirrorAndWait } from "./fake-origin.ts";
1419 import { seedAdmin } from "./helpers/admin.ts";
1520 import { proveAll } from "./helpers/verified.ts";
@@ -69,11 +74,15 @@ describe("a forge is an origin", () => {
6974 dirs.push(dir);
7075 return dir;
7176 };
72 friend = createApp({ dataDir: await make(), port: 0 });
77 // A fixed port, so the friend can go down and come back at the same address.
78 const probe = Bun.serve({ port: 0, fetch: () => new Response("") });
79 const friendPort = probe.port as number;
80 await probe.stop(true);
81 friend = createApp({ dataDir: await make(), port: friendPort });
7382 friendServers = await serve(friend);
7483 await seedAdmin(friend, { username: "bob", email: "[email protected]", password: BOB });
7584 proveAll(friend);
76 friendToken = createToken(friend.db, getUserByName(friend.db, "bob")!.id, "alice", "ro").token;
85 friendToken = createToken(friend.db, getUserByName(friend.db, "bob")!.id, "alice", "rw").token;
7786 await makeRepo("private", "secret-app");
7887 await makeRepo("public", "open-app");
7988
@@ -163,4 +172,156 @@ describe("a forge is an origin", () => {
163172 const listed = await fetch(`${friendServers.url}/api/v1/user/repos`);
164173 expect(listed.status).toBe(401);
165174 });
175
176 describe("work crosses", () => {
177 const bobApi = (path: string, init: RequestInit = {}) =>
178 fetch(`${friendServers.url}${path}`, {
179 ...init,
180 headers: {
181 "content-type": "application/json",
182 authorization: basic("bob", BOB),
183 ...(init.headers as Record<string, string> | undefined),
184 },
185 });
186 const sync = async () => {
187 const origin = getOriginById(mine.db, originId)!;
188 await syncOrigin(mine, origin);
189 };
190 const local = () => getRepo(mine.db, "alice", "secret-app")!;
191
192 test("an issue and a comment written there arrive here, under their author", async () => {
193 const made = await bobApi("/api/v1/repos/bob/secret-app/issues", {
194 method: "POST",
195 body: JSON.stringify({ title: "It crashes", body: "on start" }),
196 });
197 expect(made.status).toBe(201);
198 const number = ((await made.json()) as { number: number }).number;
199 await bobApi(`/api/v1/repos/bob/secret-app/issues/${number}/comments`, {
200 method: "POST",
201 body: JSON.stringify({ body: "me too" }),
202 });
203 await sync();
204 const issue = listIssues(mine.db, local().id, { state: "all" }).find((i) => i.title === "It crashes");
205 expect(issue?.originAuthor).toBe("bob");
206 expect(listComments(mine.db, issue!.id).map((c) => c.body)).toContain("me too");
207 });
208
209 test("a comment written here lands there", async () => {
210 const issue = listIssues(mine.db, local().id, { state: "all" })[0]!;
211 const wrote = await mineApi(`/api/v1/repos/alice/secret-app/issues/${issue.number}/comments`, {
212 method: "POST",
213 body: JSON.stringify({ body: "looking now" }),
214 });
215 expect(wrote.status).toBe(201);
216 const there = await bobApi(`/api/v1/repos/bob/secret-app/issues/${issue.number}/comments`);
217 expect(JSON.stringify(await there.json())).toContain("looking now");
218 });
219
220 test("a pull opens on the origin, and its merge comes back", async () => {
221 const edit = await bobApi("/api/v1/repos/bob/secret-app/contents", {
222 method: "POST",
223 body: JSON.stringify({
224 branch: "feature",
225 message: "a change",
226 files: [{ path: "NOTE.md", content: "hi\n" }],
227 }),
228 });
229 expect(edit.status).toBeLessThan(300);
230 const origin = getOriginById(mine.db, originId)!;
231 const provider = providerForOrigin(mine, origin);
232 expect(provider.ok).toBe(true);
233 if (!provider.ok) return;
234 const opened = await provider.value.createPull("bob/secret-app", {
235 title: "A change",
236 body: "from the other side",
237 base: "main",
238 head: "feature",
239 });
240 expect(opened.ok).toBe(true);
241 if (!opened.ok) return;
242 expect(opened.value.pull?.headBranch).toBe("feature");
243 expect(opened.value.state).toBe("open");
244 const merged = await bobApi(`/api/v1/repos/bob/secret-app/pulls/${opened.value.number}/merge`, {
245 method: "POST",
246 body: JSON.stringify({}),
247 });
248 expect(merged.status).toBeLessThan(300);
249 const again = await provider.value.getIssue("bob/secret-app", opened.value.number);
250 expect(again.ok && again.value.pull?.mergedAt).toBeTruthy();
251 await sync();
252 const ours = getPull(mine.db, local().id, opened.value.number);
253 expect(ours?.title).toBe("A change");
254 expect(ours?.mergedAt).not.toBeNull();
255 });
256
257 test("an origin that is down leaves what was brought and refuses what would be lost, then catches up", async () => {
258 const snapshot = () =>
259 listIssues(mine.db, local().id, { state: "all" }).map((i) => ({
260 number: i.number,
261 title: i.title,
262 state: i.state,
263 comments: listComments(mine.db, i.id).map((c) => c.body),
264 }));
265 const before = snapshot();
266 friendServers.stop();
267 const report = await syncOrigin(mine, getOriginById(mine.db, originId)!);
268 expect(report.error).not.toBe("");
269 expect(snapshot()).toEqual(before);
270
271 const issue = listIssues(mine.db, local().id, { state: "all" })[0]!;
272 const wrote = await mineApi(`/api/v1/repos/alice/secret-app/issues/${issue.number}/comments`, {
273 method: "POST",
274 body: JSON.stringify({ body: "while it is down" }),
275 });
276 expect(wrote.status).toBeGreaterThanOrEqual(500);
277 expect(snapshot()).toEqual(before);
278
279 // Written there while it was away from here: an issue and a comment.
280 friendServers = await serve(friend);
281 const made = await bobApi("/api/v1/repos/bob/secret-app/issues", {
282 method: "POST",
283 body: JSON.stringify({ title: "Written while down", body: "catch me up" }),
284 });
285 expect(made.status).toBe(201);
286 const number = ((await made.json()) as { number: number }).number;
287 await bobApi(`/api/v1/repos/bob/secret-app/issues/${number}/comments`, {
288 method: "POST",
289 body: JSON.stringify({ body: "missed event" }),
290 });
291 const again = await syncOrigin(mine, getOriginById(mine.db, originId)!);
292 expect(again.error).toBe("");
293 const caught = listIssues(mine.db, local().id, { state: "all" }).find(
294 (i) => i.title === "Written while down",
295 );
296 expect(caught?.originAuthor).toBe("bob");
297 expect(listComments(mine.db, caught!.id).map((c) => c.body)).toContain("missed event");
298 });
299
300 test("a comment links to its thread, not the repository", async () => {
301 const provider = providerForOrigin(mine, getOriginById(mine.db, originId)!);
302 if (!provider.ok) throw new Error("no provider");
303 const issue = listIssues(mine.db, local().id, { state: "all" })[0]!;
304 const read = await provider.value.listComments("bob/secret-app", issue.number);
305 expect(read.ok).toBe(true);
306 if (!read.ok) return;
307 const first = read.value[0]!;
308 expect(first.htmlUrl).toBe(
309 `${friendServers.url}/bob/secret-app/issues/${issue.number}#c-${first.remoteId}`,
310 );
311 });
312
313 test("a head with more than one colon is refused", async () => {
314 const provider = providerForOrigin(mine, getOriginById(mine.db, originId)!);
315 if (!provider.ok) throw new Error("no provider");
316 for (const head of ["a:b:c", ":x", "x:"]) {
317 const refused = await provider.value.createPull("bob/secret-app", {
318 title: "t",
319 body: "",
320 base: "main",
321 head,
322 });
323 expect(refused.ok).toBe(false);
324 }
325 });
326 });
166327 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs