Public: anyone can read this; the crew can change it.
After a GitHub migration the person chooses: leave GitHub alone, archive it, or keep it as a read-only mirror the forge pushes to #3730
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3408-after-a-github-migration-the-person-choo → main 4 commits fork 6 threads · 6 unresolved
README.md +18 −2
| @@ -270,13 +270,29 @@ ssh -p 2222 localhost # the terminal UI | ||
| 270 | 270 | git clone ssh://localhost:2222/you/hello.git # git, same port |
| 271 | 271 | |
| 272 | 272 | |
| 273 | **After a GitHub migration** you choose what happens to the original: leave it | |
| 274 | alone (the default), archive it, or keep it as a read-only mirror. The choice is | |
| 275 | the "Afterwards" field on `/migrate`, in the terminal's migrate screen, and | |
| 276 | `after` (`leave`, `archive`, `mirror`) on `POST /api/v1/migrate`. Archive marks | |
| 277 | the GitHub repository archived using the account you connected. Mirror leaves | |
| 278 | GitHub untouched at first, then copies later changes on the branches GitHub had | |
| 279 | at migration back to it; branches made afterwards, such as the crew's work | |
| 280 | branches and pull heads, stay on the forge. If someone pushes to GitHub | |
| 281 | meanwhile, the forge stops rather than overwrite them, and the repository's | |
| 282 | settings page (and the terminal's status line) says why it stopped and when it | |
| 283 | last pushed. "Stop mirroring" there, or `U` in the terminal, ends it and leaves | |
| 284 | GitHub as it is. Archiving needs admin | |
| 285 | rights on GitHub: without them the migration still succeeds and says the | |
| 286 | repository was not archived, so you can do it there yourself. Only repositories | |
| 287 | read through a connected origin have an original to change; a pasted URL does not. | |
| 288 | ||
| 273 | 289 | **Migrate from the terminal.** Press `M` on the dashboard (or pick "Migrate a |
| 274 | 290 | repository" in the palette, `ctrl+k`). The list is your connected origins' |
| 275 | 291 | repositories, private ones marked; `/` filters it and `u` pastes a URL instead. |
| 276 | 292 | Enter on a repository lists its branches with their last commit, your own |
| 277 | 293 | newest first and marked "(suggested)", so Enter again takes it; a pasted URL |
| 278 | skips this step. The form asks for name, owner, visibility, agent and autonomy | |
| 279 | (`tab` moves, `space` changes); `ctrl+d` migrates. The job is a checklist of | |
| 294 | skips this step. The form asks for name, owner, visibility, agent, autonomy and afterwards | |
| 295 | (leave the original alone, archive it, or keep it as a mirror; `tab` moves, `space` changes); `ctrl+d` migrates. The job is a checklist of | |
| 280 | 296 | stages: `esc` hides it and never cancels, and a toast says when it lands, on the |
| 281 | 297 | new repository. It is the same `migrateRepo` as `/migrate` and |
| 282 | 298 | `POST /api/v1/migrate`: the chosen `branch` becomes the default branch and the |
src/actions/migrate.ts +53
| @@ -29,6 +29,7 @@ import { requireVerified } from "./verify.ts"; | ||
| 29 | 29 | import type { App } from "../app.ts"; |
| 30 | 30 | import { getAgentByName } from "../core/agents.ts"; |
| 31 | 31 | import { storageStale } from "../core/storage.ts"; |
| 32 | import { setMirrorTarget } from "../core/migrate_mirror.ts"; | |
| 32 | 33 | import { getOriginFor } from "../core/origins.ts"; |
| 33 | 34 | import { getRepoById, type Repo } from "../core/repos.ts"; |
| 34 | 35 | import type { User } from "../core/users.ts"; |
| @@ -75,6 +76,13 @@ export type MigrateSource = | ||
| 75 | 76 | | { readonly originId: string; readonly fullName: string } |
| 76 | 77 | | { readonly url: string }; |
| 77 | 78 | |
| 79 | export type AfterMigration = "leave" | "archive" | "mirror"; | |
| 80 | ||
| 81 | /** The form's word, or `leave` for anything else. */ | |
| 82 | export function afterChoice(raw: string): AfterMigration { | |
| 83 | return raw === "archive" || raw === "mirror" ? raw : "leave"; | |
| 84 | } | |
| 85 | ||
| 78 | 86 | export interface MigrateInput { |
| 79 | 87 | readonly source: MigrateSource; |
| 80 | 88 | /** The actor, or an org they can write to. */ |
| @@ -91,6 +99,13 @@ export interface MigrateInput { | ||
| 91 | 99 | readonly branch?: string; |
| 92 | 100 | /** Put an agent on it: the crew makes it run here. */ |
| 93 | 101 | readonly crew?: { readonly agent: string; readonly autonomous: boolean }; |
| 102 | /** | |
| 103 | * What happens to the source afterwards, when it is on a connected | |
| 104 | * origin: left alone (the default), archived there, or kept as a | |
| 105 | * read-only mirror this forge pushes to after every landing. | |
| 106 | * @default "leave" | |
| 107 | */ | |
| 108 | readonly after?: AfterMigration; | |
| 94 | 109 | /** Name → addresses, for the private-address check. Tests inject it. */ |
| 95 | 110 | readonly resolve?: Resolve; |
| 96 | 111 | } |
| @@ -189,6 +204,7 @@ export async function migrateRepo( | ||
| 189 | 204 | void runMigration(app, user.value, repo, source.value, { |
| 190 | 205 | branch: branch === "" ? undefined : branch, |
| 191 | 206 | crew: input.crew, |
| 207 | after: input.after ?? "leave", | |
| 192 | 208 | jobId: job.id, |
| 193 | 209 | }); |
| 194 | 210 | return ok({ repo, job: getJob(job.id) ?? job }); |
| @@ -446,6 +462,7 @@ async function runMigration( | ||
| 446 | 462 | asked: { |
| 447 | 463 | readonly branch: string | undefined; |
| 448 | 464 | readonly crew: MigrateInput["crew"]; |
| 465 | readonly after: AfterMigration; | |
| 449 | 466 | readonly jobId: string; |
| 450 | 467 | }, |
| 451 | 468 | ): Promise<void> { |
| @@ -547,6 +564,7 @@ async function runMigration( | ||
| 547 | 564 | let detail = `migrated as ${where} — ${plural(branches.length, "branch", "branches")}, ${plural(refs.size - branches.length, "tag")}${conversations}`; |
| 548 | 565 | if (work !== undefined) |
| 549 | 566 | detail += `. Working on ${work.name}, ${reasonWords(work.reason)}${asked.branch !== undefined && work.reason !== "picked" ? ` (${asked.branch} did not come with the clone)` : ""}`; |
| 567 | detail += await afterWords(app, user, current, source, asked.after, refs); | |
| 550 | 568 | if (crew !== undefined) { |
| 551 | 569 | advance(jobId, { stage: "crew", detail: `putting ${crew.agent} on it` }); |
| 552 | 570 | detail += `. ${await putCrewOn(app, user, current, source, crew, work)}`; |
| @@ -561,6 +579,41 @@ async function runMigration( | ||
| 561 | 579 | } |
| 562 | 580 | } |
| 563 | 581 | |
| 582 | /** | |
| 583 | * The source, after the move, as the person chose. Only a repository read | |
| 584 | * through a connected origin has a source to touch. Archiving needs admin | |
| 585 | * there; without it the migration stands and the words say so. | |
| 586 | */ | |
| 587 | async function afterWords( | |
| 588 | app: App, | |
| 589 | user: User, | |
| 590 | repo: Repo, | |
| 591 | source: Resolved, | |
| 592 | after: AfterMigration, | |
| 593 | refs: ReadonlyMap<string, string>, | |
| 594 | ): Promise<string> { | |
| 595 | if (after === "leave" || source.provider === undefined) return ""; | |
| 596 | if (after === "archive") { | |
| 597 | const done = await source.provider.archiveRepo(source.label); | |
| 598 | if (done.ok) return `. ${source.label} is archived on its origin`; | |
| 599 | return done.error.error === "forbidden" || | |
| 600 | done.error.error === "unauthorized" || | |
| 601 | done.error.error === "not_found" | |
| 602 | ? `. ${source.label} was not archived: the connected account lacks admin rights there. Archive it on the origin yourself` | |
| 603 | : `. ${source.label} was not archived: the origin could not be reached`; | |
| 604 | } | |
| 605 | const tips: Record<string, string> = {}; | |
| 606 | for (const [ref, oid] of refs) if (ref.startsWith("refs/heads/")) tips[ref] = oid; | |
| 607 | setMirrorTarget(app.db, { | |
| 608 | repoId: repo.id, | |
| 609 | originId: source.originId, | |
| 610 | fullName: source.label, | |
| 611 | cloneUrl: source.url, | |
| 612 | tips, | |
| 613 | }); | |
| 614 | return `. ${source.label} stays as a read-only mirror: every landing here is pushed to it`; | |
| 615 | } | |
| 616 | ||
| 564 | 617 | interface Cloned { |
| 565 | 618 | readonly head: string | undefined; |
| 566 | 619 | readonly refs: ReadonlyMap<string, string>; |
src/actions/mirror_target.ts added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | /** | |
| 2 | * Stop mirroring a migrated repository to its source (#3408). The only way | |
| 3 | * out of a mirror; the source is left exactly as it is. | |
| 4 | */ | |
| 5 | ||
| 6 | import type { App } from "../app.ts"; | |
| 7 | import { deleteMirrorTarget } from "../core/migrate_mirror.ts"; | |
| 8 | import type { Repo } from "../core/repos.ts"; | |
| 9 | import type { User } from "../core/users.ts"; | |
| 10 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 11 | import { requireRepoAdmin } from "./access.ts"; | |
| 12 | ||
| 13 | export function stopMirroring( | |
| 14 | app: App, | |
| 15 | actor: User | undefined, | |
| 16 | repo: Repo, | |
| 17 | ): Result<boolean, "not_found"> { | |
| 18 | const admin = requireRepoAdmin(app, actor, repo); | |
| 19 | if (!admin.ok) return err("not_found"); | |
| 20 | return ok(deleteMirrorTarget(app.db, repo.id)); | |
| 21 | } |
src/core/migrate_mirror.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * The read-only mirror a person chose after a migration: where the forge | |
| 3 | * keeps pushing, and the branch tips it last pushed there — the lease the | |
| 4 | * next push holds. The credential is the origin's; nothing secret is here. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | ||
| 9 | export interface MirrorTarget { | |
| 10 | readonly repoId: string; | |
| 11 | readonly originId: string; | |
| 12 | readonly fullName: string; | |
| 13 | readonly cloneUrl: string; | |
| 14 | /** `refs/heads/…` → the oid the target holds. */ | |
| 15 | readonly tips: Readonly<Record<string, string>>; | |
| 16 | readonly lastError: string; | |
| 17 | /** When a push last succeeded; `""` before the first. */ | |
outdated
Recorded, never read. A mirror that stopped on a moved GitHub branch has to say so on the repository page and in the terminal, with a way to stop mirroring. That is the first thing to fix. |
||
| 18 | readonly pushedAt: string; | |
| 19 | } | |
| 20 | ||
| 21 | interface Row { | |
| 22 | repo_id: string; | |
| 23 | origin_id: string; | |
| 24 | full_name: string; | |
| 25 | clone_url: string; | |
| 26 | tips: string; | |
| 27 | last_error: string; | |
| 28 | pushed_at: string | null; | |
| 29 | } | |
| 30 | ||
| 31 | export function setMirrorTarget( | |
| 32 | db: Database, | |
| 33 | target: Omit<MirrorTarget, "lastError" | "pushedAt">, | |
| 34 | ): void { | |
| 35 | db.query( | |
| 36 | `INSERT INTO migrate_mirror (repo_id, origin_id, full_name, clone_url, tips) | |
| 37 | VALUES (?1, ?2, ?3, ?4, ?5) | |
| 38 | ON CONFLICT(repo_id) DO UPDATE SET origin_id = ?2, | |
| 39 | full_name = ?3, clone_url = ?4, tips = ?5, last_error = ''`, | |
| 40 | ).run( | |
| 41 | target.repoId, | |
| 42 | target.originId, | |
| 43 | target.fullName, | |
| 44 | target.cloneUrl, | |
| 45 | JSON.stringify(target.tips), | |
| 46 | ); | |
| 47 | } | |
| 48 | ||
| 49 | export function getMirrorTarget( | |
| 50 | db: Database, | |
| 51 | repoId: string, | |
| 52 | ): MirrorTarget | undefined { | |
| 53 | const row = db | |
| 54 | .query<Row, [string]>("SELECT * FROM migrate_mirror WHERE repo_id = ?1") | |
| 55 | .get(repoId); | |
| 56 | if (!row) return undefined; | |
| 57 | let tips: Record<string, string> = {}; | |
| 58 | try { | |
| 59 | const parsed: unknown = JSON.parse(row.tips); | |
| 60 | if (parsed !== null && typeof parsed === "object") | |
| 61 | tips = parsed as Record<string, string>; | |
| 62 | } catch { | |
| 63 | // A tips column nobody can read leases nothing: the next push creates. | |
| 64 | } | |
| 65 | return { | |
| 66 | repoId: row.repo_id, | |
| 67 | originId: row.origin_id, | |
| 68 | fullName: row.full_name, | |
| 69 | cloneUrl: row.clone_url, | |
| 70 | tips, | |
| 71 | lastError: row.last_error, | |
| 72 | pushedAt: row.pushed_at ?? "", | |
| 73 | }; | |
| 74 | } | |
| 75 | ||
| 76 | /** What a push left behind: the tips it moved, or why it did not. */ | |
| 77 | export function recordMirrorPush( | |
| 78 | db: Database, | |
| 79 | repoId: string, | |
| 80 | tips: Readonly<Record<string, string>>, | |
| 81 | error: string, | |
| 82 | ): void { | |
| 83 | db.query( | |
| 84 | `UPDATE migrate_mirror SET tips = ?2, last_error = ?3, | |
| 85 | pushed_at = CASE WHEN ?3 = '' THEN strftime('%Y-%m-%dT%H:%M:%fZ','now') ELSE pushed_at END | |
| 86 | WHERE repo_id = ?1`, | |
| 87 | ).run(repoId, JSON.stringify(tips), error); | |
| 88 | } | |
| 89 | ||
| 90 | /** Stop mirroring: the forge pushes nothing more, the source stays as it is. */ | |
| 91 | export function deleteMirrorTarget(db: Database, repoId: string): boolean { | |
| 92 | return ( | |
| 93 | db.query("DELETE FROM migrate_mirror WHERE repo_id = ?1").run(repoId) | |
| 94 | .changes > 0 | |
| 95 | ); | |
| 96 | } | |
| 97 | ||
| 98 | /** | |
| 99 | * The one line a person reads about a mirror: where it goes and when it | |
| 100 | * last went, or why it stopped. | |
| 101 | */ | |
| 102 | export function mirrorLine(target: MirrorTarget): string { | |
| 103 | if (target.lastError !== "") | |
| 104 | return `Mirrored to ${target.fullName}, stopped: ${target.lastError}`; | |
| 105 | return target.pushedAt === "" | |
| 106 | ? `Mirrored to ${target.fullName}; nothing pushed yet` | |
| 107 | : `Mirrored to ${target.fullName}; last pushed ${target.pushedAt.slice(0, 16).replace("T", " ")} UTC`; | |
| 108 | } |
src/core/migrations/20261021100000-migrate-mirror-target.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | // Where a migrated repository keeps pushing after the move (#3408): the | |
| 2 | // person chose to leave the source as a read-only mirror. One row per | |
| 3 | // repository, with the tips last pushed (the lease for the next push); the credential stays on the origin, never here. Additive. | |
| 4 | export default ` | |
| 5 | CREATE TABLE migrate_mirror ( | |
| 6 | repo_id TEXT PRIMARY KEY REFERENCES repo(id) ON DELETE CASCADE, | |
| 7 | origin_id TEXT NOT NULL REFERENCES origin(id) ON DELETE CASCADE, | |
| 8 | full_name TEXT NOT NULL, | |
| 9 | clone_url TEXT NOT NULL, | |
| 10 | tips TEXT NOT NULL DEFAULT '{}', | |
| 11 | pushed_at TEXT, | |
| 12 | last_error TEXT NOT NULL DEFAULT '' | |
| 13 | ); | |
| 14 | `; |
src/http/api.ts +13
| @@ -287,6 +287,7 @@ import { listDomains } from "../core/domains.ts"; | ||
| 287 | 287 | import type { Database } from "bun:sqlite"; |
| 288 | 288 | import { |
| 289 | 289 | migrateMessage, |
| 290 | afterChoice, | |
| 290 | 291 | migrateRepo, |
| 291 | 292 | migrationJobFor, |
| 292 | 293 | type MigrateSource, |
| @@ -5067,6 +5068,17 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5067 | 5068 | typeof input.crew === "object" && input.crew !== null |
| 5068 | 5069 | ? (input.crew as Record<string, unknown>) |
| 5069 | 5070 | : undefined; |
| 5071 | if ( | |
| 5072 | input.after !== undefined && | |
| 5073 | input.after !== "leave" && | |
| 5074 | input.after !== "archive" && | |
| 5075 | input.after !== "mirror" | |
| 5076 | ) | |
| 5077 | return apiError( | |
| 5078 | "invalid_after", | |
| 5079 | 400, | |
| 5080 | "after is one of leave, archive or mirror", | |
| 5081 | ); | |
| 5070 | 5082 | const started = await migrateRepo(ctx.app, ctx.auth.user, { |
| 5071 | 5083 | source, |
| 5072 | 5084 | ...(typeof input.owner === "string" ? { owner: input.owner } : {}), |
| @@ -5078,6 +5090,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5078 | 5090 | ...(crew !== undefined && typeof crew.agent === "string" |
| 5079 | 5091 | ? { crew: { agent: crew.agent, autonomous: crew.autonomous === true } } |
| 5080 | 5092 | : {}), |
| 5093 | after: afterChoice(typeof input.after === "string" ? input.after : ""), | |
| 5081 | 5094 | }); |
| 5082 | 5095 | if (!started.ok) |
| 5083 | 5096 | return started.error === "unauthorized" |
src/http/server.ts +2
| @@ -381,6 +381,7 @@ import { | ||
| 381 | 381 | settingsUpdate, |
| 382 | 382 | waveCut, |
| 383 | 383 | archiveSubmit, |
| 384 | mirrorStop, | |
| 384 | 385 | webhookCreate, |
| 385 | 386 | webhookDelete, |
| 386 | 387 | } from "../web/pages/settings.ts"; |
| @@ -1335,6 +1336,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1335 | 1336 | router.add("POST", "/:owner/:name/settings", settingsUpdate); |
| 1336 | 1337 | router.add("POST", "/:owner/:name/settings/wave", waveCut); |
| 1337 | 1338 | router.add("POST", "/:owner/:name/settings/archive", archiveSubmit); |
| 1339 | router.add("POST", "/:owner/:name/settings/mirror/stop", mirrorStop); | |
| 1338 | 1340 | router.add("POST", "/:owner/:name/settings/proposal-tokens", proposalTokenMint); |
| 1339 | 1341 | router.add( |
| 1340 | 1342 | "POST", |
src/main.ts +4
| @@ -34,6 +34,7 @@ import { resolveAppDomain } from "./apps/host.ts"; | ||
| 34 | 34 | import { requeueAbandonedChecks, startChecks } from "./checks/service.ts"; |
| 35 | 35 | import { RED_HOLD_MS } from "./checks/red_main.ts"; |
| 36 | 36 | import { startLooks } from "./looks/service.ts"; |
| 37 | import { startMirrorOut } from "./origins/mirror-out.ts"; | |
| 37 | 38 | import { startProbes } from "./contract/probes.ts"; |
| 38 | 39 | import { startSnapshots } from "./snapshot/service.ts"; |
| 39 | 40 | import { resolveDestination } from "./snapshot/destination.ts"; |
| @@ -345,6 +346,8 @@ const checks = | ||
| 345 | 346 | // A roll lets running checks finish this long before cutting them off. |
| 346 | 347 | drainMs: positive(process.env.FORGE_CHECKS_DRAIN_S, 300) * 1000, |
| 347 | 348 | }); |
| 349 | // Migrated repositories kept as a read-only mirror push to their source. | |
| 350 | const mirrorOut = startMirrorOut(app); | |
| 348 | 351 | // The look: a picture of every environment that comes up green, taken |
outdated
This splits the comment about the look (the two lines above) from |
||
| 349 | 352 | // through this forge's own listener. |
| 350 | 353 | const looks = appsOn ? startLooks(app, { localUrl: servers.url }) : undefined; |
| @@ -614,6 +617,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> { | ||
| 614 | 617 | // A step leads a process group of its own, so a terminal's Ctrl-C no |
| 615 | 618 | // longer ends it: a person's stop cuts checks off (queued again) at once. |
| 616 | 619 | checks?.stop({ drain: signal === "SIGTERM" }), |
| 620 | mirrorOut(), | |
| 617 | 621 | looks?.stop(), |
| 618 | 622 | probes?.stop(), |
| 619 | 623 | snapshots.stop(), |
src/origins/mirror-out.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 2 | * Mirror out: after a landing, a repository migrated with "keep the source | |
| 3 | * as a read-only mirror" pushes to it (`pushToMirrorTarget`). It listens for | |
| 4 | * the two events that move a branch — a pull landing and a push — and runs | |
| 5 | * one push at a time per repository; a burst coalesces into the push that | |
| 6 | * follows it, because the push sends whatever moved, not whatever was heard. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { getMirrorTarget } from "../core/migrate_mirror.ts"; | |
| 11 | import { getRepoById } from "../core/repos.ts"; | |
| 12 | import { pushToMirrorTarget } from "./push-through.ts"; | |
| 13 | ||
| 14 | export function startMirrorOut(app: App): () => void { | |
| 15 | const running = new Set<string>(); | |
| 16 | const again = new Set<string>(); | |
| 17 | ||
| 18 | async function run(repoId: string): Promise<void> { | |
| 19 | if (running.has(repoId)) { | |
| 20 | again.add(repoId); | |
| 21 | return; | |
| 22 | } | |
| 23 | running.add(repoId); | |
| 24 | try { | |
| 25 | do { | |
| 26 | again.delete(repoId); | |
| 27 | const repo = getRepoById(app.db, repoId); | |
| 28 | if (repo) await pushToMirrorTarget(app, repo); | |
| 29 | } while (again.has(repoId)); | |
| 30 | } catch (error) { | |
| 31 | app.log.error("mirror push failed", { repoId, error: String(error) }); | |
| 32 | } finally { | |
| 33 | running.delete(repoId); | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | return app.bus.subscribe((event) => { | |
| 38 | if (event.repoId === null) return; | |
| 39 | if (event.kind !== "pull.merged" && event.kind !== "push") return; | |
| 40 | // The migration's own arrival event: the target already holds that history. | |
| 41 | if (event.payload["via"] === "migrate") return; | |
| 42 | if (!getMirrorTarget(app.db, event.repoId)) return; | |
| 43 | void run(event.repoId); | |
| 44 | }); | |
| 45 | } |
src/origins/providers/forgejo.ts +12
| @@ -717,6 +717,18 @@ export class ForgejoProvider implements Provider { | ||
| 717 | 717 | return succeed(toRepo(result.value.data)); |
| 718 | 718 | } |
| 719 | 719 | |
| 720 | async archiveRepo(fullName: string): Promise<ProviderResult<void>> { | |
| 721 | const named = splitFullName(fullName); | |
| 722 | if (!named) return fail("not_found", `not a repository name: ${fullName}`); | |
| 723 | const result = await this.api.call({ | |
| 724 | path: `/repos/${seg(named.owner)}/${seg(named.name)}`, | |
| 725 | method: "PATCH", | |
| 726 | body: { archived: true }, | |
| 727 | }); | |
| 728 | if (!result.ok) return result; | |
| 729 | return succeed(undefined); | |
| 730 | } | |
| 731 | ||
| 720 | 732 | /** |
| 721 | 733 | * Git over HTTPS takes the token as a Basic password, with the username |
| 722 | 734 | * ignored — so a personal access token pairs with the login it belongs to, |
src/origins/providers/github.ts +12
| @@ -649,6 +649,18 @@ export class GitHubProvider implements Provider { | ||
| 649 | 649 | return succeed(toRepo(result.value.data)); |
| 650 | 650 | } |
| 651 | 651 | |
| 652 | async archiveRepo(fullName: string): Promise<ProviderResult<void>> { | |
| 653 | const named = splitFullName(fullName); | |
| 654 | if (!named) return fail("not_found", `not a repository name: ${fullName}`); | |
| 655 | const result = await this.api.call({ | |
| 656 | path: `/repos/${seg(named.owner)}/${seg(named.name)}`, | |
| 657 | method: "PATCH", | |
| 658 | body: { archived: true }, | |
| 659 | }); | |
| 660 | if (!result.ok) return result; | |
| 661 | return succeed(undefined); | |
| 662 | } | |
| 663 | ||
| 652 | 664 | /** |
| 653 | 665 | * GitHub ignores the username when the password is a valid token; |
| 654 | 666 | * `x-access-token` is the convention its own docs use, and it works for |
src/origins/push-through.ts +65 −2
| @@ -26,7 +26,12 @@ | ||
| 26 | 26 | */ |
| 27 | 27 | |
| 28 | 28 | import type { App } from "../app.ts"; |
| 29 | import { detachedMirror, originForRepo } from "../core/origins.ts"; | |
| 29 | import { getMirrorTarget, recordMirrorPush } from "../core/migrate_mirror.ts"; | |
| 30 | import { | |
| 31 | detachedMirror, | |
| 32 | getOriginById, | |
| 33 | originForRepo, | |
| 34 | } from "../core/origins.ts"; | |
| 30 | 35 | import type { Repo } from "../core/repos.ts"; |
| 31 | 36 | import { FORWARDED_GIT_ENV, type RefUpdate } from "../git/hook-protocol.ts"; |
| 32 | 37 | import { fetchInto, resolveOid, writeRef } from "../git/merge.ts"; |
| @@ -34,7 +39,7 @@ import { repoDir } from "../git/repos.ts"; | ||
| 34 | 39 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 35 | 40 | import { providerForOrigin } from "./credential.ts"; |
| 36 | 41 | import { redact, secretsIn } from "./credentials.ts"; |
| 37 | import { pushToOrigin } from "./git-mirror.ts"; | |
| 42 | import { pushToOrigin, snapshotRefs } from "./git-mirror.ts"; | |
| 38 | 43 | |
| 39 | 44 | /** A push-through must not outlive a pusher's patience. */ |
| 40 | 45 | const TIMEOUT_MS = 60_000; |
| @@ -215,3 +220,61 @@ function quarantineEnv( | ||
| 215 | 220 | } |
| 216 | 221 | return env; |
| 217 | 222 | } |
| 223 | ||
| 224 | /** | |
| 225 | * The read-only mirror a migration left behind: after a landing, every | |
| 226 | * branch the source held at migration that moved goes to it under the credential of | |
| 227 | * the person who chose it — the same rules as a push through a mirror | |
| 228 | * (header in env, explicit refspec and lease, `refs/pull/*` never). The | |
| 229 | * lease is what the target last held, so a target that moved on its own | |
| 230 | * refuses the push and says so instead of being overwritten. Answers how | |
| 231 | * many branches went; a failure is recorded on the target, not thrown. | |
| 232 | */ | |
| 233 | export async function pushToMirrorTarget( | |
| 234 | app: App, | |
| 235 | repo: Repo, | |
| 236 | ): Promise<Result<number, PushThroughRefusal>> { | |
| 237 | const target = getMirrorTarget(app.db, repo.id); | |
| 238 | if (!target) return ok(0); | |
| 239 | const fail = (message: string): Result<number, PushThroughRefusal> => { | |
| 240 | recordMirrorPush(app.db, repo.id, target.tips, message); | |
| 241 | return err({ message }); | |
| 242 | }; | |
| 243 | const origin = getOriginById(app.db, target.originId); | |
| 244 | if (!origin) return fail("the origin was disconnected; reconnect it"); | |
| 245 | const provider = providerForOrigin(app, origin); | |
| 246 | if (!provider.ok) return fail("the origin needs to be reconnected"); | |
| 247 | const authEnv = await provider.value.gitAuthEnv(target.cloneUrl); | |
| 248 | if (Object.keys(authEnv).length === 0) | |
| 249 | return fail("the origin needs to be reconnected"); | |
| 250 | ||
| 251 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 252 | const { refs } = await snapshotRefs(dir); | |
| 253 | const updates = [...refs] | |
| 254 | .filter( | |
| 255 | ([ref, oid]) => | |
| 256 | // Only what the source held at migration: the crew's work branches | |
outdated
This sends every branch, including crew work branches and unreviewed pull heads, to GitHub under the person's credential, which may make it public. Limit it to the branches the source held at migration ( |
||
| 257 | // and pull heads are unreviewed and never leave the forge. | |
| 258 | Object.hasOwn(target.tips, ref) && target.tips[ref] !== oid, | |
| 259 | ) | |
| 260 | .map(([ref, newOid]) => ({ | |
| 261 | ref, | |
| 262 | oldOid: target.tips[ref] ?? ZERO_OID_STRING, | |
| 263 | newOid, | |
| 264 | })); | |
| 265 | if (updates.length === 0) return ok(0); | |
| 266 | const pushed = await pushToOrigin(dir, target.cloneUrl, updates, authEnv, { | |
| 267 | timeoutMs: TIMEOUT_MS, | |
| 268 | }); | |
| 269 | if (!pushed.ok) | |
| 270 | return fail( | |
| 271 | redact( | |
| 272 | `${target.fullName} refused the mirror push: ${pushed.error.message}`, | |
| 273 | ...secretsIn(authEnv), | |
| 274 | ), | |
| 275 | ); | |
| 276 | const tips = { ...target.tips }; | |
| 277 | for (const u of updates) tips[u.ref] = u.newOid; | |
| 278 | recordMirrorPush(app.db, repo.id, tips, ""); | |
| 279 | return ok(updates.length); | |
| 280 | } |
src/origins/types.ts +6
| @@ -384,6 +384,12 @@ export interface Provider { | ||
| 384 | 384 | * a caller must poll `getRepo` until it does (`actions/contribute.ts`). |
| 385 | 385 | */ |
| 386 | 386 | forkRepo(fullName: string): Promise<ProviderResult<RemoteRepo>>; |
| 387 | /** | |
| 388 | * Archive a repo (`PATCH /repos/{o}/{r}` with `archived: true`): read-only | |
| 389 | * on the origin from then on. Needs admin there; without it the answer is | |
| 390 | * `forbidden` and nothing changed. | |
| 391 | */ | |
| 392 | archiveRepo(fullName: string): Promise<ProviderResult<void>>; | |
| 387 | 393 | |
| 388 | 394 | /** |
| 389 | 395 | * What git needs to talk to this origin over HTTPS with the credential, |
src/tui/screens/migrate.ts +14 −2
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { listAllAgents } from "../../actions/crew.ts"; |
| 12 | 12 | import { |
| 13 | type AfterMigration, | |
| 13 | 14 | migrateMessage, |
| 14 | 15 | migrateRepo, |
| 15 | 16 | type MigrateSource, |
| @@ -272,7 +273,14 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen { | ||
| 272 | 273 | return self; |
| 273 | 274 | } |
| 274 | 275 | |
| 275 | type Focus = 0 | 1 | 2 | 3 | 4; | |
| 276 | type Focus = 0 | 1 | 2 | 3 | 4 | 5; | |
| 277 | ||
| 278 | /** What becomes of the source afterwards: the web form's three choices. */ | |
| 279 | const AFTER: readonly { id: AfterMigration; text: string }[] = [ | |
| 280 | { id: "leave", text: "leave it alone" }, | |
| 281 | { id: "archive", text: "archive it on the origin" }, | |
| 282 | { id: "mirror", text: "keep it as a read-only mirror the forge pushes to" }, | |
| 283 | ]; | |
| 276 | 284 | |
| 277 | 285 | /** Step three: where it lands, and who works on it. */ |
| 278 | 286 | function migrateFormScreen( |
| @@ -293,6 +301,7 @@ function migrateFormScreen( | ||
| 293 | 301 | let visibility: "public" | "private" = "private"; |
| 294 | 302 | let agentIndex = 0; |
| 295 | 303 | let autonomous = false; |
| 304 | let afterIndex = 0; | |
| 296 | 305 | let focus: Focus = 0; |
| 297 | 306 | let error: string | undefined; |
| 298 | 307 | let busy = false; |
| @@ -308,6 +317,7 @@ function migrateFormScreen( | ||
| 308 | 317 | owner: owners[ownerIndex] ?? user.username, |
| 309 | 318 | name, |
| 310 | 319 | visibility, |
| 320 | after: AFTER[afterIndex]?.id ?? "leave", | |
| 311 | 321 | ...(picked.branch === undefined ? {} : { branch: picked.branch }), |
| 312 | 322 | ...(agent === "" ? {} : { crew: { agent, autonomous } }), |
| 313 | 323 | }); |
| @@ -332,6 +342,7 @@ function migrateFormScreen( | ||
| 332 | 342 | if (focus === 2) visibility = visibility === "private" ? "public" : "private"; |
| 333 | 343 | if (focus === 3) agentIndex = (agentIndex + 1) % crew.length; |
| 334 | 344 | if (focus === 4) autonomous = !autonomous; |
| 345 | if (focus === 5) afterIndex = (afterIndex + 1) % AFTER.length; | |
| 335 | 346 | }; |
| 336 | 347 | |
| 337 | 348 | return { |
| @@ -371,6 +382,7 @@ function migrateFormScreen( | ||
| 371 | 382 | `visibility ${visibility}`, |
| 372 | 383 | `agent ${crew[agentIndex] === "" ? "nobody — just the repository" : crew[agentIndex]}`, |
| 373 | 384 | `autonomy ${autonomous ? "autonomous — green ships on its own" : "every green change waits for your look"}`, |
| 385 | `afterwards ${AFTER[afterIndex]?.text ?? ""}`, | |
| 374 | 386 | ]; |
| 375 | 387 | lines.forEach((line, i) => |
| 376 | 388 | s.text( |
| @@ -396,7 +408,7 @@ function migrateFormScreen( | ||
| 396 | 408 | return true; |
| 397 | 409 | } |
| 398 | 410 | if (e.key === "tab") { |
| 399 | focus = ((focus + 1) % 5) as Focus; | |
| 411 | focus = ((focus + 1) % 6) as Focus; | |
| 400 | 412 | return true; |
| 401 | 413 | } |
| 402 | 414 | if (focus > 0 && (e.key === " " || e.key === "enter")) { |
src/tui/screens/workspace.ts +25
| @@ -40,6 +40,8 @@ import { createBranch } from "../../actions/branches.ts"; | ||
| 40 | 40 | import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts"; |
| 41 | 41 | import { isPinned } from "../../core/pins.ts"; |
| 42 | 42 | import { setDecisionsBy } from "../../actions/repos.ts"; |
| 43 | import { stopMirroring } from "../../actions/mirror_target.ts"; | |
| 44 | import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts"; | |
| 43 | 45 | import { canAdmin, DECISIONS_BY, getRepoById, type Repo } from "../../core/repos.ts"; |
| 44 | 46 | import type { TreeEntry } from "../../git/read.ts"; |
| 45 | 47 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| @@ -1334,6 +1336,11 @@ export function workspaceScreen( | ||
| 1334 | 1336 | }), |
| 1335 | 1337 | binding([","], "settings", { disabled: !admin() }), |
| 1336 | 1338 | ]), |
| 1339 | ...(repo !== undefined && | |
| 1340 | admin() && | |
| 1341 | getMirrorTarget(f.db, repo.id) !== undefined | |
| 1342 | ? [binding(["U"], "stop mirroring to the source")] | |
| 1343 | : []), | |
| 1337 | 1344 | binding(["r"], "refresh"), |
| 1338 | 1345 | binding( |
| 1339 | 1346 | ["E"], |
| @@ -1682,6 +1689,21 @@ export function workspaceScreen( | ||
| 1682 | 1689 | }); |
| 1683 | 1690 | return true; |
| 1684 | 1691 | } |
| 1692 | if ( | |
| 1693 | plain && | |
| 1694 | e.key === "U" && | |
| 1695 | repo !== undefined && | |
| 1696 | admin() && | |
| 1697 | getMirrorTarget(f.db, repo.id) !== undefined | |
| 1698 | ) { | |
| 1699 | const stopped = stopMirroring(f.app, f.user, repo); | |
| 1700 | ctx.toast( | |
| 1701 | stopped.ok && stopped.value ? "stopped mirroring" : "not mirrored", | |
| 1702 | stopped.ok && stopped.value ? "success" : "danger", | |
| 1703 | ); | |
| 1704 | ctx.invalidate(); | |
| 1705 | return true; | |
| 1706 | } | |
| 1685 | 1707 | if (plain && e.key === "F" && f.signedIn) { |
| 1686 | 1708 | // A repository that lives at an origin is forked *there* — a local |
| 1687 | 1709 | // fork of a mirror would be a copy of a copy, with nowhere to |
| @@ -2149,8 +2171,11 @@ export function workspaceScreen( | ||
| 2149 | 2171 | |
| 2150 | 2172 | const dirty = tabs.filter((x) => x.modified).length; |
| 2151 | 2173 | const target = proposal(); |
| 2174 | // A mirror that stopped says so on every frame, not only in settings. | |
| 2175 | const held = repo === undefined ? undefined : getMirrorTarget(f.db, repo.id); | |
| 2152 | 2176 | const parts = [ |
| 2153 | 2177 | ref === "" ? "no ref" : ref, |
| 2178 | held === undefined ? "" : mirrorLine(held), | |
| 2154 | 2179 | dirty === 0 ? "" : `${dirty} modified`, |
| 2155 | 2180 | pending.size === 0 ? "" : `${plural(pending.size, "deletion")} pending`, |
| 2156 | 2181 | // Said on every frame, not only in the dialog: somebody typing into a |
src/web/pages/migrate.ts +29
| @@ -20,8 +20,10 @@ import { repositoryLimitMessage } from "../../actions/repos.ts"; | ||
| 20 | 20 | import { listAllAgents } from "../../actions/crew.ts"; |
| 21 | 21 | import { |
| 22 | 22 | migrateMessage, |
| 23 | afterChoice, | |
| 23 | 24 | migrateRepo, |
| 24 | 25 | migrationJobFor, |
| 26 | type AfterMigration, | |
| 25 | 27 | type MigrateSource, |
| 26 | 28 | } from "../../actions/migrate.ts"; |
| 27 | 29 | import { listRemoteRepos, listUserOrigins } from "../../actions/origins.ts"; |
| @@ -68,6 +70,7 @@ interface MigrateValues { | ||
| 68 | 70 | visibility: string; |
| 69 | 71 | agent: string; |
| 70 | 72 | autonomous: boolean; |
| 73 | after: AfterMigration; | |
| 71 | 74 | /** The branch asked for; "" lets the work-branch rule choose. */ |
| 72 | 75 | branch: string; |
| 73 | 76 | /** Draw the picked repository's branches: Branches was pressed, or one was chosen. */ |
| @@ -486,6 +489,29 @@ function migrateForm( | ||
| 486 | 489 | > |
| 487 | 490 | </label> |
| 488 | 491 | </div>`} |
| 492 | <h3 class="mb mt">What happens to the GitHub repository</h3> | |
Minor: the origin can be Forgejo too. "What happens to the original" is accurate for both. |
||
| 493 | ${selectField({ | |
| 494 | label: "Afterwards", | |
| 495 | name: "after", | |
| 496 | hint: "Applies to a repository picked from a connected origin. Archiving needs admin rights there; without them the migration still succeeds and says so.", | |
| 497 | options: [ | |
| 498 | { | |
| 499 | value: "leave", | |
| 500 | label: "Leave it alone", | |
| 501 | selected: values.after === "leave", | |
| 502 | }, | |
| 503 | { | |
| 504 | value: "archive", | |
| 505 | label: "Archive it on the origin", | |
| 506 | selected: values.after === "archive", | |
| 507 | }, | |
| 508 | { | |
| 509 | value: "mirror", | |
| 510 | label: "Keep it as a read-only mirror this forge pushes to", | |
| 511 | selected: values.after === "mirror", | |
| 512 | }, | |
| 513 | ], | |
| 514 | })} | |
| 489 | 515 | <div class="form-actions"> |
| 490 | 516 | ${btn("Migrate", { variant: "primary", icon: "download" })} |
| 491 | 517 | ${btn("Cancel", { href: "/", variant: "ghost" })} |
| @@ -587,6 +613,7 @@ export async function migratePage(ctx: Ctx): Promise<Response> { | ||
| 587 | 613 | // unless the link named one; "Nobody" is one choice away. |
| 588 | 614 | agent: linked.agent ?? names[0] ?? "", |
| 589 | 615 | autonomous: false, |
| 616 | after: "leave", | |
| 590 | 617 | branch: (q.get("branch") ?? "").slice(0, 300), |
| 591 | 618 | showBranches: q.get("branches") === "1" || (q.get("branch") ?? "") !== "", |
| 592 | 619 | }, |
| @@ -612,6 +639,7 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> { | ||
| 612 | 639 | visibility: field(form, "visibility") === "public" ? "public" : "private", |
| 613 | 640 | agent: field(form, "agent"), |
| 614 | 641 | autonomous: field(form, "autonomous") !== "", |
| 642 | after: afterChoice(field(form, "after")), | |
| 615 | 643 | // A branch belongs to the repository its list was drawn for: another |
| 616 | 644 | // row's button migrates that row by the rule, not by this choice. |
| 617 | 645 | branch: |
| @@ -650,6 +678,7 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> { | ||
| 650 | 678 | ...(values.agent === "" |
| 651 | 679 | ? {} |
| 652 | 680 | : { crew: { agent: values.agent, autonomous: values.autonomous } }), |
| 681 | after: values.after, | |
| 653 | 682 | }); |
| 654 | 683 | if (!started.ok) { |
| 655 | 684 | if (started.error === "forbidden_owner" || started.error === "unauthorized") |
src/web/pages/settings.ts +34 −1
| @@ -73,6 +73,8 @@ import { | ||
| 73 | 73 | setAppMembersThrough, |
| 74 | 74 | } from "../../actions/app_members.ts"; |
| 75 | 75 | import { canAdmin, type Repo } from "../../core/repos.ts"; |
| 76 | import { stopMirroring } from "../../actions/mirror_target.ts"; | |
| 77 | import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts"; | |
| 76 | 78 | import { listWebhooks, recentDeliveries } from "../../core/webhooks.ts"; |
| 77 | 79 | import { listRefs } from "../../git/read.ts"; |
| 78 | 80 | import { repoDir } from "../../git/repos.ts"; |
| @@ -365,12 +367,28 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 365 | 367 | </form>`, |
| 366 | 368 | { head: html`${icon("folder")}<h2>Archive</h2>` }, |
| 367 | 369 | ); |
| 370 | const held = getMirrorTarget(ctx.app.db, repo.id); | |
| 371 | const mirror = | |
| 372 | held === undefined | |
| 373 | ? null | |
| 374 | : card( | |
| 375 | html`<form method="post" action="${base}/settings/mirror/stop"> | |
| 376 | <p class="muted" data-mirror-line>${mirrorLine(held)}</p> | |
| 377 | <p class="muted small"> | |
| 378 | Stopping leaves ${held.fullName} as it is; nothing more is pushed. | |
| 379 | </p> | |
| 380 | <div class="form-actions"> | |
| 381 | ${btn("Stop mirroring", { variant: "primary" })} | |
Minor: DESIGN.md allows one filled button per page. This page already breaks that rule, and this adds one more. |
||
| 382 | </div> | |
| 383 | </form>`, | |
| 384 | { head: html`${icon("branch")}<h2>Mirror</h2>` }, | |
| 385 | ); | |
| 368 | 386 | return settingsPageResponse( |
| 369 | 387 | ctx, |
| 370 | 388 | repo, |
| 371 | 389 | "general", |
| 372 | 390 | "Settings", |
| 373 | html`${lead}<div class="mt">${body}</div><div class="mt">${wave}</div><div class="mt">${archive}</div>${proposals | |
| 391 | html`${lead}${mirror ? html`<div class="mt">${mirror}</div>` : null}<div class="mt">${body}</div><div class="mt">${wave}</div><div class="mt">${archive}</div>${proposals | |
| 374 | 392 | ? html`<div class="mt">${proposals}</div>` |
| 375 | 393 | : null}${secrets |
| 376 | 394 | ? html`<div class="mt">${secrets}</div>` |
| @@ -456,6 +474,21 @@ export async function archiveSubmit(ctx: Ctx): Promise<Response> { | ||
| 456 | 474 | ); |
| 457 | 475 | } |
| 458 | 476 | |
| 477 | /** POST /:owner/:name/settings/mirror/stop — stop mirroring to the source. */ | |
| 478 | export async function mirrorStop(ctx: Ctx): Promise<Response> { | |
| 479 | const gate = await adminForm(ctx); | |
| 480 | if (gate instanceof Response) return gate; | |
| 481 | const { repo } = gate; | |
| 482 | const stopped = stopMirroring(ctx.app, ctx.auth.user, repo); | |
| 483 | return flashTo( | |
| 484 | ctx, | |
| 485 | `${repoBase(repo)}/settings`, | |
| 486 | stopped.ok && stopped.value | |
| 487 | ? { message: "Stopped mirroring." } | |
| 488 | : { tone: "danger", message: "This repository is not being mirrored." }, | |
| 489 | ); | |
| 490 | } | |
| 491 | ||
| 459 | 492 | /** POST /:owner/:name/settings/wave — open the draft of the next wave. */ |
| 460 | 493 | export async function waveCut(ctx: Ctx): Promise<Response> { |
| 461 | 494 | const gate = await adminForm(ctx); |
test/fake-origin.ts +8
| @@ -53,6 +53,8 @@ export interface FakeRepo { | ||
| 53 | 53 | openPulls?: number; |
| 54 | 54 | /** KiB, as both forges report `size`. */ |
| 55 | 55 | sizeKb?: number; |
| 56 | /** Set by a successful `PATCH` with `archived: true`. */ | |
| 57 | archived?: boolean; | |
| 56 | 58 | } |
| 57 | 59 | |
| 58 | 60 | export interface FakeIssue { |
| @@ -407,6 +409,12 @@ function route( | ||
| 407 | 409 | return jsonResponse({ message: "Not Found" }, 404); |
| 408 | 410 | } |
| 409 | 411 | |
| 412 | if (tail === "" && method === "PATCH") { | |
| 413 | if (repo.admin === false) | |
| 414 | return jsonResponse({ message: "Must have admin rights to Repository." }, 403); | |
| 415 | if (parse(bodyText).archived === true) repo.archived = true; | |
| 416 | return jsonResponse(repoJson(state, repo)); | |
| 417 | } | |
| 410 | 418 | if (tail === "") return jsonResponse(repoJson(state, repo)); |
| 411 | 419 | |
| 412 | 420 | // ---- forking ---- |
test/migrate-after.test.ts added +262
| @@ -0,0 +1,262 @@ | ||
| 1 | /** | |
| 2 | * What happens to the GitHub repository after a migration (#3408): left | |
| 3 | * alone, archived there, or kept as a read-only mirror the forge pushes to. | |
| 4 | * The source is a repository on this forge standing in for GitHub's git | |
| 5 | * side; the fake GitHub's request log is the API side. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { afterChoice, migrateRepo } from "../src/actions/migrate.ts"; | |
| 14 | import { connectOrigin } from "../src/actions/origins.ts"; | |
| 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { getMirrorTarget, mirrorLine } from "../src/core/migrate_mirror.ts"; | |
| 17 | import { getRepo } from "../src/core/repos.ts"; | |
| 18 | import { createSession } from "../src/core/sessions.ts"; | |
| 19 | import { createToken } from "../src/core/tokens.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { resolveOid } from "../src/git/merge.ts"; | |
| 22 | import { runGitCapture } from "../src/git/env.ts"; | |
| 23 | import { repoDir } from "../src/git/repos.ts"; | |
| 24 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 25 | import { settled } from "../src/origins/jobs.ts"; | |
| 26 | import { startMirrorOut } from "../src/origins/mirror-out.ts"; | |
| 27 | import { forgetProviders } from "../src/origins/providers/index.ts"; | |
| 28 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; | |
| 29 | import { quietLogs } from "./helpers/globals.ts"; | |
| 30 | import { promote } from "./helpers/members.ts"; | |
| 31 | ||
| 32 | quietLogs(); | |
| 33 | ||
| 34 | describe("migrate: after", () => { | |
| 35 | let dataDir: string; | |
| 36 | let app: App; | |
| 37 | let servers: Servers; | |
| 38 | let github: FakeForge; | |
| 39 | let alice: User; | |
| 40 | let octo: User; | |
| 41 | let originId = ""; | |
| 42 | let stopMirror = () => {}; | |
| 43 | let n = 0; | |
| 44 | ||
| 45 | const person = async (username: string): Promise<User> => { | |
| 46 | const made = await createUser(app.db, { | |
| 47 | origin: "seed", | |
| 48 | trusted: true, | |
| 49 | username, | |
| 50 | email: `${username}@forge.test`, | |
| 51 | adminIfFirst: username === "alice", | |
| 52 | }); | |
| 53 | if (!made.ok) throw new Error(String(made.error)); | |
| 54 | promote(app.db, made.value); | |
| 55 | return made.value; | |
| 56 | }; | |
| 57 | ||
| 58 | const commit = async (user: User, owner: string, name: string, path: string) => { | |
| 59 | const repo = getRepo(app.db, owner, name)!; | |
| 60 | const done = await commitToBranch(app, user, repo, { | |
| 61 | branch: "main", | |
| 62 | message: path, | |
| 63 | files: [{ path, content: `${path}\n` }], | |
| 64 | }); | |
| 65 | if (!done.ok) throw new Error(String(done.error)); | |
| 66 | }; | |
| 67 | ||
| 68 | /** A fresh GitHub repository (octo's, on this forge), migrated by alice. */ | |
| 69 | const migrate = async (after: "leave" | "archive" | "mirror", admin = true) => { | |
| 70 | const name = `gizmo${++n}`; | |
| 71 | const made = await createRepo(app, octo, { name, visibility: "private" }); | |
| 72 | if (!made.ok) throw new Error(made.error); | |
| 73 | await commit(octo, "octo", name, "README.md"); | |
| 74 | github.state.repos.push({ | |
| 75 | id: 100 + n, | |
| 76 | fullName: `octo/${name}`, | |
| 77 | cloneUrl: `http://127.0.0.1:${servers.port}/octo/${name}.git`, | |
| 78 | defaultBranch: "main", | |
| 79 | private: true, | |
| 80 | admin, | |
| 81 | push: true, | |
| 82 | }); | |
| 83 | github.state.requests.length = 0; | |
| 84 | const started = await migrateRepo(app, alice, { | |
| 85 | source: { originId, fullName: `octo/${name}` }, | |
| 86 | name: `copy${n}`, | |
| 87 | after, | |
| 88 | }); | |
| 89 | if (!started.ok) throw new Error(started.error); | |
| 90 | const job = await settled(started.value.job.id); | |
| 91 | return { name, job, copy: `copy${n}` }; | |
| 92 | }; | |
| 93 | ||
| 94 | const writes = () => | |
| 95 | github.state.requests.filter((r) => r.method !== "GET" && r.method !== "HEAD"); | |
| 96 | ||
| 97 | beforeAll(async () => { | |
| 98 | dataDir = await mkdtemp(join(tmpdir(), "forge-migrate-after-")); | |
| 99 | app = createApp({ dataDir, port: 0, migrateFromPrivateHosts: true }); | |
| 100 | servers = await serve(app); | |
| 101 | alice = await person("alice"); | |
| 102 | octo = await person("octo"); | |
| 103 | const secret = createToken(app.db, octo.id, "upstream", "rw").token; | |
| 104 | github = serveFakeForge(newFakeState("github", "octo", secret)); | |
| 105 | forgetProviders(dataDir); | |
| 106 | const connected = await connectOrigin(app, alice, { | |
| 107 | host: github.url, | |
| 108 | kind: "github", | |
| 109 | token: secret, | |
| 110 | }); | |
| 111 | if (!connected.ok) throw new Error(connected.error); | |
| 112 | originId = connected.value.id; | |
| 113 | stopMirror = startMirrorOut(app); | |
| 114 | }, 30_000); | |
| 115 | ||
| 116 | afterAll(async () => { | |
| 117 | stopMirror(); | |
| 118 | github.stop(); | |
| 119 | servers.stop(); | |
| 120 | app.close(); | |
| 121 | forgetProviders(dataDir); | |
| 122 | await rm(dataDir, { recursive: true, force: true }); | |
| 123 | }); | |
| 124 | ||
| 125 | test("the form's word: anything but archive or mirror leaves it alone", () => { | |
| 126 | expect(afterChoice("archive")).toBe("archive"); | |
| 127 | expect(afterChoice("mirror")).toBe("mirror"); | |
| 128 | expect(afterChoice("")).toBe("leave"); | |
| 129 | expect(afterChoice("delete")).toBe("leave"); | |
| 130 | }); | |
| 131 | ||
| 132 | test("/migrate offers the three choices, leaving it alone by default", async () => { | |
| 133 | const html = await ( | |
| 134 | await fetch(`http://127.0.0.1:${servers.port}/migrate`, { | |
| 135 | headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` }, | |
| 136 | }) | |
| 137 | ).text(); | |
| 138 | expect(html).toContain('name="after"'); | |
| 139 | expect(html).toMatch(/<option\s[^>]*value="leave"[^>]*selected|<option\s[^>]*selected[^>]*value="leave"/); | |
| 140 | expect(html).toContain('value="archive"'); | |
| 141 | expect(html).toContain('value="mirror"'); | |
| 142 | }); | |
| 143 | ||
| 144 | test("leave alone: no write reaches GitHub, and no mirror is kept", async () => { | |
| 145 | const { job, copy } = await migrate("leave"); | |
| 146 | expect(job?.stage).toBe("done"); | |
| 147 | expect(writes()).toEqual([]); | |
| 148 | expect(getMirrorTarget(app.db, getRepo(app.db, "alice", copy)!.id)).toBeUndefined(); | |
| 149 | }); | |
| 150 | ||
| 151 | test("archive: one PATCH archived=true under the connected credential", async () => { | |
| 152 | const { job, name } = await migrate("archive"); | |
| 153 | expect(job?.stage).toBe("done"); | |
| 154 | expect(job?.detail).toContain(`octo/${name} is archived on its origin`); | |
| 155 | const w = writes(); | |
| 156 | expect(w.length).toBe(1); | |
| 157 | expect(w[0]!.method).toBe("PATCH"); | |
| 158 | expect(w[0]!.path).toMatch(new RegExp(`/repos/octo/${name}$`)); | |
| 159 | expect(JSON.parse(w[0]!.body)).toEqual({ archived: true }); | |
| 160 | expect(w[0]!.authorization).not.toBe(""); | |
| 161 | expect(github.state.repos.find((r) => r.fullName === `octo/${name}`)!.archived).toBe(true); | |
| 162 | }); | |
| 163 | ||
| 164 | test("archive without admin there: said in plain words, the migration still succeeds", async () => { | |
| 165 | const { job, name, copy } = await migrate("archive", false); | |
| 166 | expect(job?.stage).toBe("done"); | |
| 167 | expect(job?.detail).toContain(`octo/${name} was not archived: the connected account lacks admin rights there`); | |
| 168 | expect(github.state.repos.find((r) => r.fullName === `octo/${name}`)!.archived).toBeUndefined(); | |
| 169 | expect(getRepo(app.db, "alice", copy)).toBeDefined(); | |
| 170 | }); | |
| 171 | ||
| 172 | test("mirror: no write at migration, then one push to GitHub after a landing", async () => { | |
| 173 | const { job, name, copy } = await migrate("mirror"); | |
| 174 | expect(job?.stage).toBe("done"); | |
| 175 | expect(job?.detail).toContain("read-only mirror"); | |
| 176 | expect(writes()).toEqual([]); | |
| 177 | ||
| 178 | const sourceDir = repoDir(app.reposRoot, "octo", name); | |
| 179 | const before = await resolveOid(sourceDir, "refs/heads/main"); | |
| 180 | await commit(alice, "alice", copy, "landed.txt"); | |
| 181 | const mine = await resolveOid(repoDir(app.reposRoot, "alice", copy), "refs/heads/main"); | |
| 182 | expect(mine).not.toBe(before); | |
| 183 | for (let i = 0; i < 100; i++) { | |
| 184 | const held = getMirrorTarget(app.db, getRepo(app.db, "alice", copy)!.id); | |
| 185 | if (held?.tips["refs/heads/main"] === mine) break; | |
| 186 | await Bun.sleep(50); | |
| 187 | } | |
| 188 | expect(await resolveOid(sourceDir, "refs/heads/main")).toBe(mine); | |
| 189 | // The credential travelled as a header in git's environment: the API | |
| 190 | // log has no write, and the stored target holds no secret. | |
| 191 | expect(writes()).toEqual([]); | |
| 192 | const target = getMirrorTarget(app.db, getRepo(app.db, "alice", copy)!.id)!; | |
| 193 | expect(target.tips["refs/heads/main"]).toBe(mine!); | |
| 194 | expect(target.lastError).toBe(""); | |
| 195 | expect(JSON.stringify(target)).not.toContain("token"); | |
| 196 | }); | |
| 197 | ||
| 198 | /** The copy's mirror target, once `ok` says the push has settled. */ | |
| 199 | const settledTarget = async (copy: string, ok: (t: NonNullable<ReturnType<typeof getMirrorTarget>>) => boolean) => { | |
| 200 | for (let i = 0; i < 100; i++) { | |
| 201 | const held = getMirrorTarget(app.db, getRepo(app.db, "alice", copy)!.id); | |
| 202 | if (held !== undefined && ok(held)) return held; | |
| 203 | await Bun.sleep(50); | |
| 204 | } | |
| 205 | throw new Error("the mirror push did not settle"); | |
| 206 | }; | |
| 207 | ||
| 208 | test("mirror: the crew's work branches never go to the source", async () => { | |
| 209 | const { name, copy } = await migrate("mirror"); | |
| 210 | const copyDir = repoDir(app.reposRoot, "alice", copy); | |
| 211 | const main = (await resolveOid(copyDir, "refs/heads/main"))!; | |
| 212 | await runGitCapture(["update-ref", "refs/heads/scout/unreviewed", main], { cwd: copyDir }); | |
| 213 | await commit(alice, "alice", copy, "landed.txt"); | |
| 214 | const mine = (await resolveOid(copyDir, "refs/heads/main"))!; | |
| 215 | await settledTarget(copy, (t) => t.tips["refs/heads/main"] === mine); | |
| 216 | const sourceDir = repoDir(app.reposRoot, "octo", name); | |
| 217 | expect(await resolveOid(sourceDir, "refs/heads/scout/unreviewed")).toBeUndefined(); | |
| 218 | expect(await resolveOid(sourceDir, "refs/heads/main")).toBe(mine); | |
| 219 | }); | |
| 220 | ||
| 221 | test("mirror: a source that moved on its own is not overwritten, and the error is recorded and shown", async () => { | |
| 222 | const { name, copy } = await migrate("mirror"); | |
| 223 | const sourceDir = repoDir(app.reposRoot, "octo", name); | |
| 224 | await commit(octo, "octo", name, "theirs.txt"); | |
| 225 | const theirs = (await resolveOid(sourceDir, "refs/heads/main"))!; | |
| 226 | await commit(alice, "alice", copy, "landed.txt"); | |
| 227 | const held = await settledTarget(copy, (t) => t.lastError !== ""); | |
| 228 | expect(held.lastError).toContain(`octo/${name}`); | |
| 229 | expect(await resolveOid(sourceDir, "refs/heads/main")).toBe(theirs); | |
| 230 | expect(mirrorLine(held)).toContain("stopped"); | |
| 231 | ||
| 232 | // The settings page says so, and Stop mirroring is the way out. | |
| 233 | const base = `http://127.0.0.1:${servers.port}`; | |
| 234 | const cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 235 | const page = await (await fetch(`${base}/alice/${copy}/settings`, { headers: { cookie } })).text(); | |
| 236 | expect(page).toContain(`Mirrored to octo/${name}, stopped:`); | |
| 237 | expect(page).toContain("Stop mirroring"); | |
| 238 | const stop = (headers: Record<string, string>) => | |
| 239 | fetch(`${base}/alice/${copy}/settings/mirror/stop`, { method: "POST", headers, body: "", redirect: "manual" }); | |
| 240 | expect((await stop({ cookie, origin: "http://evil.example" })).status).toBe(403); | |
| 241 | expect(getMirrorTarget(app.db, getRepo(app.db, "alice", copy)!.id)).toBeDefined(); | |
| 242 | const octoCookie = `forge_session=${createSession(app.db, octo.id).token}`; | |
| 243 | expect((await stop({ cookie: octoCookie, origin: base })).status).toBeGreaterThanOrEqual(400); | |
| 244 | expect(getMirrorTarget(app.db, getRepo(app.db, "alice", copy)!.id)).toBeDefined(); | |
| 245 | expect((await stop({ cookie, origin: base })).status).toBe(303); | |
| 246 | expect(getMirrorTarget(app.db, getRepo(app.db, "alice", copy)!.id)).toBeUndefined(); | |
| 247 | }); | |
| 248 | ||
| 249 | test("POST /api/v1/migrate with an unknown `after` is a 400 naming the three words", async () => { | |
| 250 | const token = createToken(app.db, alice.id, "api", "admin").token; | |
| 251 | const res = await fetch(`http://127.0.0.1:${servers.port}/api/v1/migrate`, { | |
| 252 | method: "POST", | |
| 253 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 254 | body: JSON.stringify({ source: { url: "https://github.com/octo/x" }, after: "delete" }), | |
| 255 | }); | |
| 256 | expect(res.status).toBe(400); | |
| 257 | const said = JSON.stringify(await res.json()); | |
| 258 | expect(said).toContain("leave"); | |
| 259 | expect(said).toContain("archive"); | |
| 260 | expect(said).toContain("mirror"); | |
| 261 | }); | |
| 262 | }); |
test/tui-migrate.test.ts +34
| @@ -304,3 +304,37 @@ describe("migrate from the terminal", () => { | ||
| 304 | 304 | } |
| 305 | 305 | }); |
| 306 | 306 | }); |
| 307 | ||
| 308 | describe("migrate from the terminal: afterwards", () => { | |
| 309 | test("the form offers leave by default; choosing archive archives the source with one PATCH {archived:true}", async () => { | |
| 310 | const terminal = headlessTerminal(120, 40); | |
| 311 | const { app: tui } = createForgeApp(app, identity(alice), { terminal, exitHooks: false }); | |
| 312 | tui.start(); | |
| 313 | try { | |
| 314 | await settleApp(tui); | |
| 315 | terminal.feed("M"); | |
| 316 | await settleApp(tui); | |
| 317 | terminal.feed("/widgets\r"); // filter, then | |
| 318 | terminal.feed("\r"); // the repository: its branches | |
| 319 | await settleApp(tui); | |
| 320 | terminal.feed("\r"); // the suggested branch → form | |
| 321 | await settleApp(tui); | |
| 322 | expect(tui.screen.toText()).toContain("afterwards leave it alone"); | |
| 323 | terminal.feed("\x7f\x7f\x7f\x7f\x7f\x7f\x7f"); // clear the name | |
| 324 | terminal.feed("archived-copy"); | |
| 325 | terminal.feed("\t\t\t\t\t "); // owner, visibility, agent, autonomy, afterwards → archive | |
| 326 | await settleApp(tui); | |
| 327 | expect(tui.screen.toText()).toContain("afterwards archive it"); | |
| 328 | fake.state.requests.length = 0; | |
| 329 | terminal.feed("\x04"); | |
| 330 | await until(() => getRepo(app.db, "alice", "archived-copy") !== undefined); | |
| 331 | await until(() => fake.state.requests.some((r) => r.method === "PATCH")); | |
| 332 | const writes = fake.state.requests.filter((r) => r.method !== "GET" && r.method !== "HEAD"); | |
| 333 | expect(writes.length).toBe(1); | |
| 334 | expect(writes[0]!.method).toBe("PATCH"); | |
| 335 | expect(JSON.parse(writes[0]!.body)).toEqual({ archived: true }); | |
| 336 | } finally { | |
| 337 | tui.stop?.(); | |
| 338 | } | |
| 339 | }, 60_000); | |
| 340 | }); |
test/tui-workspace.test.ts +25
| @@ -21,6 +21,7 @@ import { join } from "node:path"; | ||
| 21 | 21 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 22 | 22 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 23 | 23 | import { audienceFor } from "../src/core/audience.ts"; |
| 24 | import { getMirrorTarget, recordMirrorPush, setMirrorTarget } from "../src/core/migrate_mirror.ts"; | |
| 24 | 25 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 25 | 26 | import { listPulls } from "../src/core/pulls.ts"; |
| 26 | 27 | import type { User } from "../src/core/users.ts"; |
| @@ -204,6 +205,30 @@ describe("the terminal workspace", () => { | ||
| 204 | 205 | await rm(dataDir, { recursive: true, force: true }); |
| 205 | 206 | }); |
| 206 | 207 | |
| 208 | test("a mirror that stopped says why in the status line, and U stops mirroring", async () => { | |
| 209 | forge.db.run("PRAGMA foreign_keys = OFF"); | |
| 210 | setMirrorTarget(forge.db, { | |
| 211 | repoId: repo.id, | |
| 212 | originId: "origin-none", | |
| 213 | fullName: "octo/lighthouse", | |
| 214 | cloneUrl: "https://example.test/octo/lighthouse.git", | |
| 215 | tips: {}, | |
| 216 | }); | |
| 217 | recordMirrorPush(forge.db, repo.id, {}, "octo/lighthouse refused the mirror push: moved"); | |
| 218 | const h = boot(); | |
| 219 | try { | |
| 220 | await h.settle(); | |
| 221 | await open(h); | |
| 222 | expect(h.text()).toContain("Mirrored to octo/lighthouse, stopped:"); | |
| 223 | await h.press("U"); | |
| 224 | expect(getMirrorTarget(forge.db, repo.id)).toBeUndefined(); | |
| 225 | expect(h.text()).not.toContain("Mirrored to octo/lighthouse"); | |
| 226 | } finally { | |
| 227 | h.stop(); | |
| 228 | forge.db.run("PRAGMA foreign_keys = ON"); | |
| 229 | } | |
| 230 | }); | |
| 231 | ||
| 207 | 232 | // ---- arriving ---- |
| 208 | 233 | |
| 209 | 234 | test("a repository opens on its README, with the keys in the file pane", async () => { |
This reads as internals. Say where a person makes the choice (on the page, in the terminal, as
afteron the API) and what each choice does for them. The terminal paragraph just below should mention the new field too, once the terminal has it.