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

Merged opened this

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

Merged

scout/forge:scout/3408-after-a-github-migration-the-person-choo → main 4 commits fork 6 threads · 6 unresolved

  • Merged. scout/3408-after-a-github-migration-the-person-choo was merged into main as b15b374.

Review

Sign in to review this change.
21 files changed +804 −7
README.md +18 −2
@@ -270,13 +270,29 @@ ssh -p 2222 localhost # the terminal UI
270270 git clone ssh://localhost:2222/you/hello.git # git, same port
271271 ```
272272
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
outdated

This reads as internals. Say where a person makes the choice (on the page, in the terminal, as after on 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.

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
273289 **Migrate from the terminal.** Press `M` on the dashboard (or pick "Migrate a
274290 repository" in the palette, `ctrl+k`). The list is your connected origins'
275291 repositories, private ones marked; `/` filters it and `u` pastes a URL instead.
276292 Enter on a repository lists its branches with their last commit, your own
277293 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
280296 stages: `esc` hides it and never cancels, and a toast says when it lands, on the
281297 new repository. It is the same `migrateRepo` as `/migrate` and
282298 `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";
2929 import type { App } from "../app.ts";
3030 import { getAgentByName } from "../core/agents.ts";
3131 import { storageStale } from "../core/storage.ts";
32 import { setMirrorTarget } from "../core/migrate_mirror.ts";
3233 import { getOriginFor } from "../core/origins.ts";
3334 import { getRepoById, type Repo } from "../core/repos.ts";
3435 import type { User } from "../core/users.ts";
@@ -75,6 +76,13 @@ export type MigrateSource =
7576 | { readonly originId: string; readonly fullName: string }
7677 | { readonly url: string };
7778
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
7886 export interface MigrateInput {
7987 readonly source: MigrateSource;
8088 /** The actor, or an org they can write to. */
@@ -91,6 +99,13 @@ export interface MigrateInput {
9199 readonly branch?: string;
92100 /** Put an agent on it: the crew makes it run here. */
93101 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;
94109 /** Name → addresses, for the private-address check. Tests inject it. */
95110 readonly resolve?: Resolve;
96111 }
@@ -189,6 +204,7 @@ export async function migrateRepo(
189204 void runMigration(app, user.value, repo, source.value, {
190205 branch: branch === "" ? undefined : branch,
191206 crew: input.crew,
207 after: input.after ?? "leave",
192208 jobId: job.id,
193209 });
194210 return ok({ repo, job: getJob(job.id) ?? job });
@@ -446,6 +462,7 @@ async function runMigration(
446462 asked: {
447463 readonly branch: string | undefined;
448464 readonly crew: MigrateInput["crew"];
465 readonly after: AfterMigration;
449466 readonly jobId: string;
450467 },
451468 ): Promise<void> {
@@ -547,6 +564,7 @@ async function runMigration(
547564 let detail = `migrated as ${where} — ${plural(branches.length, "branch", "branches")}, ${plural(refs.size - branches.length, "tag")}${conversations}`;
548565 if (work !== undefined)
549566 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);
550568 if (crew !== undefined) {
551569 advance(jobId, { stage: "crew", detail: `putting ${crew.agent} on it` });
552570 detail += `. ${await putCrewOn(app, user, current, source, crew, work)}`;
@@ -561,6 +579,41 @@ async function runMigration(
561579 }
562580 }
563581
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
564617 interface Cloned {
565618 readonly head: string | undefined;
566619 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";
287287 import type { Database } from "bun:sqlite";
288288 import {
289289 migrateMessage,
290 afterChoice,
290291 migrateRepo,
291292 migrationJobFor,
292293 type MigrateSource,
@@ -5067,6 +5068,17 @@ export function apiRoutes(router: Router<Handler>): void {
50675068 typeof input.crew === "object" && input.crew !== null
50685069 ? (input.crew as Record<string, unknown>)
50695070 : 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 );
50705082 const started = await migrateRepo(ctx.app, ctx.auth.user, {
50715083 source,
50725084 ...(typeof input.owner === "string" ? { owner: input.owner } : {}),
@@ -5078,6 +5090,7 @@ export function apiRoutes(router: Router<Handler>): void {
50785090 ...(crew !== undefined && typeof crew.agent === "string"
50795091 ? { crew: { agent: crew.agent, autonomous: crew.autonomous === true } }
50805092 : {}),
5093 after: afterChoice(typeof input.after === "string" ? input.after : ""),
50815094 });
50825095 if (!started.ok)
50835096 return started.error === "unauthorized"
src/http/server.ts +2
@@ -381,6 +381,7 @@ import {
381381 settingsUpdate,
382382 waveCut,
383383 archiveSubmit,
384 mirrorStop,
384385 webhookCreate,
385386 webhookDelete,
386387 } from "../web/pages/settings.ts";
@@ -1335,6 +1336,7 @@ export function buildRouter(): Router<Handler> {
13351336 router.add("POST", "/:owner/:name/settings", settingsUpdate);
13361337 router.add("POST", "/:owner/:name/settings/wave", waveCut);
13371338 router.add("POST", "/:owner/:name/settings/archive", archiveSubmit);
1339 router.add("POST", "/:owner/:name/settings/mirror/stop", mirrorStop);
13381340 router.add("POST", "/:owner/:name/settings/proposal-tokens", proposalTokenMint);
13391341 router.add(
13401342 "POST",
src/main.ts +4
@@ -34,6 +34,7 @@ import { resolveAppDomain } from "./apps/host.ts";
3434 import { requeueAbandonedChecks, startChecks } from "./checks/service.ts";
3535 import { RED_HOLD_MS } from "./checks/red_main.ts";
3636 import { startLooks } from "./looks/service.ts";
37 import { startMirrorOut } from "./origins/mirror-out.ts";
3738 import { startProbes } from "./contract/probes.ts";
3839 import { startSnapshots } from "./snapshot/service.ts";
3940 import { resolveDestination } from "./snapshot/destination.ts";
@@ -345,6 +346,8 @@ const checks =
345346 // A roll lets running checks finish this long before cutting them off.
346347 drainMs: positive(process.env.FORGE_CHECKS_DRAIN_S, 300) * 1000,
347348 });
349 // Migrated repositories kept as a read-only mirror push to their source.
350 const mirrorOut = startMirrorOut(app);
348351 // 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 const looks. Put the mirror-out comment and const mirrorOut above "The look: …".

349352 // through this forge's own listener.
350353 const looks = appsOn ? startLooks(app, { localUrl: servers.url }) : undefined;
@@ -614,6 +617,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> {
614617 // A step leads a process group of its own, so a terminal's Ctrl-C no
615618 // longer ends it: a person's stop cuts checks off (queued again) at once.
616619 checks?.stop({ drain: signal === "SIGTERM" }),
620 mirrorOut(),
617621 looks?.stop(),
618622 probes?.stop(),
619623 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 {
717717 return succeed(toRepo(result.value.data));
718718 }
719719
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
720732 /**
721733 * Git over HTTPS takes the token as a Basic password, with the username
722734 * 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 {
649649 return succeed(toRepo(result.value.data));
650650 }
651651
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
652664 /**
653665 * GitHub ignores the username when the password is a valid token;
654666 * `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 @@
2626 */
2727
2828 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";
3035 import type { Repo } from "../core/repos.ts";
3136 import { FORWARDED_GIT_ENV, type RefUpdate } from "../git/hook-protocol.ts";
3237 import { fetchInto, resolveOid, writeRef } from "../git/merge.ts";
@@ -34,7 +39,7 @@ import { repoDir } from "../git/repos.ts";
3439 import { err, ok, type Result } from "../kernel/result.ts";
3540 import { providerForOrigin } from "./credential.ts";
3641 import { redact, secretsIn } from "./credentials.ts";
37 import { pushToOrigin } from "./git-mirror.ts";
42 import { pushToOrigin, snapshotRefs } from "./git-mirror.ts";
3843
3944 /** A push-through must not outlive a pusher's patience. */
4045 const TIMEOUT_MS = 60_000;
@@ -215,3 +220,61 @@ function quarantineEnv(
215220 }
216221 return env;
217222 }
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 (target.tips keys) or the default branch.

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 {
384384 * a caller must poll `getRepo` until it does (`actions/contribute.ts`).
385385 */
386386 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>>;
387393
388394 /**
389395 * What git needs to talk to this origin over HTTPS with the credential,
src/tui/screens/migrate.ts +14 −2
@@ -10,6 +10,7 @@
1010
1111 import { listAllAgents } from "../../actions/crew.ts";
1212 import {
13 type AfterMigration,
1314 migrateMessage,
1415 migrateRepo,
1516 type MigrateSource,
@@ -272,7 +273,14 @@ export function migrateScreen(f: Session, ctx: Ctx): Screen {
272273 return self;
273274 }
274275
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 ];
276284
277285 /** Step three: where it lands, and who works on it. */
278286 function migrateFormScreen(
@@ -293,6 +301,7 @@ function migrateFormScreen(
293301 let visibility: "public" | "private" = "private";
294302 let agentIndex = 0;
295303 let autonomous = false;
304 let afterIndex = 0;
296305 let focus: Focus = 0;
297306 let error: string | undefined;
298307 let busy = false;
@@ -308,6 +317,7 @@ function migrateFormScreen(
308317 owner: owners[ownerIndex] ?? user.username,
309318 name,
310319 visibility,
320 after: AFTER[afterIndex]?.id ?? "leave",
311321 ...(picked.branch === undefined ? {} : { branch: picked.branch }),
312322 ...(agent === "" ? {} : { crew: { agent, autonomous } }),
313323 });
@@ -332,6 +342,7 @@ function migrateFormScreen(
332342 if (focus === 2) visibility = visibility === "private" ? "public" : "private";
333343 if (focus === 3) agentIndex = (agentIndex + 1) % crew.length;
334344 if (focus === 4) autonomous = !autonomous;
345 if (focus === 5) afterIndex = (afterIndex + 1) % AFTER.length;
335346 };
336347
337348 return {
@@ -371,6 +382,7 @@ function migrateFormScreen(
371382 `visibility ${visibility}`,
372383 `agent ${crew[agentIndex] === "" ? "nobody — just the repository" : crew[agentIndex]}`,
373384 `autonomy ${autonomous ? "autonomous — green ships on its own" : "every green change waits for your look"}`,
385 `afterwards ${AFTER[afterIndex]?.text ?? ""}`,
374386 ];
375387 lines.forEach((line, i) =>
376388 s.text(
@@ -396,7 +408,7 @@ function migrateFormScreen(
396408 return true;
397409 }
398410 if (e.key === "tab") {
399 focus = ((focus + 1) % 5) as Focus;
411 focus = ((focus + 1) % 6) as Focus;
400412 return true;
401413 }
402414 if (focus > 0 && (e.key === " " || e.key === "enter")) {
src/tui/screens/workspace.ts +25
@@ -40,6 +40,8 @@ import { createBranch } from "../../actions/branches.ts";
4040 import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts";
4141 import { isPinned } from "../../core/pins.ts";
4242 import { setDecisionsBy } from "../../actions/repos.ts";
43 import { stopMirroring } from "../../actions/mirror_target.ts";
44 import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts";
4345 import { canAdmin, DECISIONS_BY, getRepoById, type Repo } from "../../core/repos.ts";
4446 import type { TreeEntry } from "../../git/read.ts";
4547 import type { RepoFacts, RepoSource } from "../../view/source.ts";
@@ -1334,6 +1336,11 @@ export function workspaceScreen(
13341336 }),
13351337 binding([","], "settings", { disabled: !admin() }),
13361338 ]),
1339 ...(repo !== undefined &&
1340 admin() &&
1341 getMirrorTarget(f.db, repo.id) !== undefined
1342 ? [binding(["U"], "stop mirroring to the source")]
1343 : []),
13371344 binding(["r"], "refresh"),
13381345 binding(
13391346 ["E"],
@@ -1682,6 +1689,21 @@ export function workspaceScreen(
16821689 });
16831690 return true;
16841691 }
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 }
16851707 if (plain && e.key === "F" && f.signedIn) {
16861708 // A repository that lives at an origin is forked *there* — a local
16871709 // fork of a mirror would be a copy of a copy, with nowhere to
@@ -2149,8 +2171,11 @@ export function workspaceScreen(
21492171
21502172 const dirty = tabs.filter((x) => x.modified).length;
21512173 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);
21522176 const parts = [
21532177 ref === "" ? "no ref" : ref,
2178 held === undefined ? "" : mirrorLine(held),
21542179 dirty === 0 ? "" : `${dirty} modified`,
21552180 pending.size === 0 ? "" : `${plural(pending.size, "deletion")} pending`,
21562181 // 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";
2020 import { listAllAgents } from "../../actions/crew.ts";
2121 import {
2222 migrateMessage,
23 afterChoice,
2324 migrateRepo,
2425 migrationJobFor,
26 type AfterMigration,
2527 type MigrateSource,
2628 } from "../../actions/migrate.ts";
2729 import { listRemoteRepos, listUserOrigins } from "../../actions/origins.ts";
@@ -68,6 +70,7 @@ interface MigrateValues {
6870 visibility: string;
6971 agent: string;
7072 autonomous: boolean;
73 after: AfterMigration;
7174 /** The branch asked for; "" lets the work-branch rule choose. */
7275 branch: string;
7376 /** Draw the picked repository's branches: Branches was pressed, or one was chosen. */
@@ -486,6 +489,29 @@ function migrateForm(
486489 >
487490 </label>
488491 </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 })}
489515 <div class="form-actions">
490516 ${btn("Migrate", { variant: "primary", icon: "download" })}
491517 ${btn("Cancel", { href: "/", variant: "ghost" })}
@@ -587,6 +613,7 @@ export async function migratePage(ctx: Ctx): Promise<Response> {
587613 // unless the link named one; "Nobody" is one choice away.
588614 agent: linked.agent ?? names[0] ?? "",
589615 autonomous: false,
616 after: "leave",
590617 branch: (q.get("branch") ?? "").slice(0, 300),
591618 showBranches: q.get("branches") === "1" || (q.get("branch") ?? "") !== "",
592619 },
@@ -612,6 +639,7 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> {
612639 visibility: field(form, "visibility") === "public" ? "public" : "private",
613640 agent: field(form, "agent"),
614641 autonomous: field(form, "autonomous") !== "",
642 after: afterChoice(field(form, "after")),
615643 // A branch belongs to the repository its list was drawn for: another
616644 // row's button migrates that row by the rule, not by this choice.
617645 branch:
@@ -650,6 +678,7 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> {
650678 ...(values.agent === ""
651679 ? {}
652680 : { crew: { agent: values.agent, autonomous: values.autonomous } }),
681 after: values.after,
653682 });
654683 if (!started.ok) {
655684 if (started.error === "forbidden_owner" || started.error === "unauthorized")
src/web/pages/settings.ts +34 −1
@@ -73,6 +73,8 @@ import {
7373 setAppMembersThrough,
7474 } from "../../actions/app_members.ts";
7575 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";
7678 import { listWebhooks, recentDeliveries } from "../../core/webhooks.ts";
7779 import { listRefs } from "../../git/read.ts";
7880 import { repoDir } from "../../git/repos.ts";
@@ -365,12 +367,28 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
365367 </form>`,
366368 { head: html`${icon("folder")}<h2>Archive</h2>` },
367369 );
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. variant: "outline" (or danger, since it ends something) would fit better.

382 </div>
383 </form>`,
384 { head: html`${icon("branch")}<h2>Mirror</h2>` },
385 );
368386 return settingsPageResponse(
369387 ctx,
370388 repo,
371389 "general",
372390 "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
374392 ? html`<div class="mt">${proposals}</div>`
375393 : null}${secrets
376394 ? html`<div class="mt">${secrets}</div>`
@@ -456,6 +474,21 @@ export async function archiveSubmit(ctx: Ctx): Promise<Response> {
456474 );
457475 }
458476
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
459492 /** POST /:owner/:name/settings/wave — open the draft of the next wave. */
460493 export async function waveCut(ctx: Ctx): Promise<Response> {
461494 const gate = await adminForm(ctx);
test/fake-origin.ts +8
@@ -53,6 +53,8 @@ export interface FakeRepo {
5353 openPulls?: number;
5454 /** KiB, as both forges report `size`. */
5555 sizeKb?: number;
56 /** Set by a successful `PATCH` with `archived: true`. */
57 archived?: boolean;
5658 }
5759
5860 export interface FakeIssue {
@@ -407,6 +409,12 @@ function route(
407409 return jsonResponse({ message: "Not Found" }, 404);
408410 }
409411
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 }
410418 if (tail === "") return jsonResponse(repoJson(state, repo));
411419
412420 // ---- 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", () => {
304304 }
305305 });
306306 });
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";
2121 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
2222 import { createApp, type App as Forge } from "../src/app.ts";
2323 import { audienceFor } from "../src/core/audience.ts";
24 import { getMirrorTarget, recordMirrorPush, setMirrorTarget } from "../src/core/migrate_mirror.ts";
2425 import { getRepo, type Repo } from "../src/core/repos.ts";
2526 import { listPulls } from "../src/core/pulls.ts";
2627 import type { User } from "../src/core/users.ts";
@@ -204,6 +205,30 @@ describe("the terminal workspace", () => {
204205 await rm(dataDir, { recursive: true, force: true });
205206 });
206207
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
207232 // ---- arriving ----
208233
209234 test("a repository opens on its README, with the keys in the file pane", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs