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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8903-work-crosses-forges-pulls-issues-and-rev → main 3 commits fork
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 | ||
| 4 | 4 | |
| 5 | 5 | - **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. |
| 6 | 6 | - **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. | |
| 8 | 9 | |
| 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 @@ | ||
| 4 | 4 | * A forge answers the Gitea dialect only for the one list connecting needs, |
| 5 | 5 | * `GET /user/repos`; who you are and one repository are read in its own |
| 6 | 6 | * 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. | |
| 11 | 10 | */ |
| 12 | 11 | |
| 13 | 12 | import type { |
| 13 | ListIssuesQuery, | |
| 14 | 14 | Page, |
| 15 | RemoteComment, | |
| 15 | 16 | ProviderResult, |
| 16 | 17 | RemoteIdentity, |
| 17 | 18 | RemoteLabel, |
| @@ -21,8 +22,10 @@ import type { | ||
| 21 | 22 | } from "../types.ts"; |
| 22 | 23 | import { ForgejoProvider, nextPage, toRepo } from "./forgejo.ts"; |
| 23 | 24 | import { NO_ACCOUNT } from "../words.ts"; |
| 25 | import { forgeCursor, toForgeComment, toForgeWork } from "./forge-work.ts"; | |
| 24 | 26 | import { asArray, asString, dig, fail, field, seg, splitFullName, succeed } from "./http.ts"; |
| 25 | 27 | |
| 28 | const MAX_COMMENT_PAGES = 1000; | |
| 26 | 29 | const NONE = { ok: true, value: [] } as const; |
| 27 | 30 | |
| 28 | 31 | export class ForgeProvider extends ForgejoProvider { |
| @@ -89,7 +92,148 @@ export class ForgeProvider extends ForgejoProvider { | ||
| 89 | 92 | return Promise.resolve(NONE); |
| 90 | 93 | } |
| 91 | 94 | |
| 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)); | |
| 94 | 238 | } |
| 95 | 239 | } |
src/origins/words.ts +1 −1
| @@ -24,4 +24,4 @@ export const PATH_IS_DIRECTORY = "that path is a directory, not a file"; | ||
| 24 | 24 | * and in the API, so a mirror of a friend's app never reads as the whole of it. |
| 25 | 25 | */ |
| 26 | 26 | 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"; | ||
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 6 | import { createToken } from "../src/core/tokens.ts"; |
| 7 | 7 | 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"; | |
| 8 | 12 | import { discover } from "../src/origins/discover.ts"; |
| 13 | import { providerForOrigin } from "../src/origins/credential.ts"; | |
| 9 | 14 | import { resolveOid } from "../src/git/merge.ts"; |
| 10 | 15 | import { repoDir } from "../src/git/repos.ts"; |
| 11 | 16 | 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"; | |
| 13 | 18 | import { mirrorAndWait } from "./fake-origin.ts"; |
| 14 | 19 | import { seedAdmin } from "./helpers/admin.ts"; |
| 15 | 20 | import { proveAll } from "./helpers/verified.ts"; |
| @@ -69,11 +74,15 @@ describe("a forge is an origin", () => { | ||
| 69 | 74 | dirs.push(dir); |
| 70 | 75 | return dir; |
| 71 | 76 | }; |
| 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 }); | |
| 73 | 82 | friendServers = await serve(friend); |
| 74 | 83 | await seedAdmin(friend, { username: "bob", email: "[email protected]", password: BOB }); |
| 75 | 84 | 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; | |
| 77 | 86 | await makeRepo("private", "secret-app"); |
| 78 | 87 | await makeRepo("public", "open-app"); |
| 79 | 88 | |
| @@ -163,4 +172,156 @@ describe("a forge is an origin", () => { | ||
| 163 | 172 | const listed = await fetch(`${friendServers.url}/api/v1/user/repos`); |
| 164 | 173 | expect(listed.status).toBe(401); |
| 165 | 174 | }); |
| 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 | }); | |
| 166 | 327 | }); |
new above
offline