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

Repo transfer, step one: `POST …/transfer {newOwner}` moves the repository's rows and storage, and old web and API paths answer 308 #10072

Merged opened this

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

Merged

scout/forge:scout/569-repo-transfer-step-one-post-transfer-new → main 4 commits fork

  • Merged. scout/569-repo-transfer-step-one-post-transfer-new was merged into main as 51bdeb1.

Review

Sign in to review this change.
17 files changed +867 −1
AGENTS.md +2
@@ -15,3 +15,5 @@ A new read of a table that grows (`event`, `issue`, `run`, `comment`) names the
1515 A new `/settings…` page route goes in `src/http/settings_routes.ts` (`settingsRoutes`), not `src/http/server.ts`.
1616
1717 State shared between modules through process memory, not the database, is named here. `src/perf/views_report.ts`: `readVisits` holds each scope and window's visits for 15 seconds (dropped by `flushViews`), so a page and its probe share one read; `src/perf/views_store.ts`: the view buffer, flushed every 10 seconds. The traffic sources view's terminal door is `src/tui/screens/trafficview.ts` (palette entry for admins, per-app command for a repository's admins). `src/runs/held.ts`: each dispatcher pass (`src/crew/dispatch.ts`, `pump`) records which queued agent runs it held and why (valve, pace, limit), and vitals (`src/kernel/vitals.ts`) leaves those out of `queue:agent`. A record older than `HELD_FRESH_MS` is ignored, so a stopped dispatcher cannot hide its queue.
18
19 `src/core/transfer.ts`: the old owner/name pairs a transfer left behind (`repo_redirect`), held per database after the first request reads them and dropped by `transferRepo`, so a request under a path nothing moved from costs no statement (#569).
docs/features/repo-transfer.md added +31
@@ -0,0 +1,31 @@
1 # Moving a repository to another owner
2
3 From the terminal: on a repository's screen, `:` then "Move this repository
4 to another owner", and type the new owner. From the API:
5 `POST /api/v1/repos/:owner/:name/transfer` with `{"newOwner": "<account or org>"}`
6 moves a repository (an `rw` token; the caller administers the repository and
7 may create repositories under the new owner — themselves, or an org they can
8 write to). It is refused when the new owner already has a repository by that
9 name.
10
11 What moves: the repository row's owner and the bare repository on disk, in
12 one step — the directory is renamed back if the database write fails.
13 Everything keyed by the repository (its app, secrets, crew, autonomy,
14 direction, issues and pulls) travels with it; a fork's link to its objects is
15 rewritten. Nothing else on disk is named by owner/name: an app's data lives
16 under its app id, crew checkouts and install caches under the repository's
17 id, and mirrors under its id, so none of them move. Moves run one at a time,
18 so two moves to the same owner/name cannot both pass the checks.
19
20 Who moved what from where to where is kept twice: an audit row in
21 `repo_transfer`, written in the move's own transaction and never rewritten,
22 and a `repo.transferred` event for the repository's readers.
23
24 Old paths keep working: a web or API request under the old `owner/name`
25 answers `308` to the same path under the new owner, for reads and writes
26 alike (a write is never applied silently; the client re-sends it). Someone
27 who cannot read the repository still gets `404`. Making a new repository at
28 the old path takes the path back.
29
30 Not yet: git remotes under the old path, runs and previews follow in the next
31 step.
src/actions/transfer.ts added +63
@@ -0,0 +1,63 @@
1 /**
2 * Transfer a repository to another owner (#569). The caller administers the
3 * repository and may create repositories under the new owner (themselves, or
4 * an org they can write to — the same rule as making one there). An agent
5 * never moves anything. The move is `core/transfer.ts`; the event
6 * (`repo.transferred`, for the repository's readers) and the audit row
7 * (`repo_transfer`, written in the move's own transaction) say who moved what
8 * where.
9 */
10
11 import type { App } from "../app.ts";
12 import { getUserByName, type User } from "../core/users.ts";
13 import { canAdmin, type Repo } from "../core/repos.ts";
14 import { transferRepo, type TransferError } from "../core/transfer.ts";
15 import { storageChanged } from "../core/storage.ts";
16 import { err, ok, type Result } from "../kernel/result.ts";
17 import { resolveOwner } from "./repos.ts";
18
19 export type TransferActionError = TransferError | "not_found" | "forbidden_owner" | "unknown_owner";
20
21 export const TRANSFER_SAID: Readonly<Record<Exclude<TransferActionError, "not_found">, string>> = {
22 name_taken: "The new owner already has a repository by that name.",
23 same_owner: "The repository already belongs to that owner.",
24 move_failed: "The repository could not be moved; nothing changed.",
25 forbidden_owner: "You cannot create repositories under that owner.",
26 unknown_owner: "No account or organisation has that name.",
27 };
28
29 /** The HTTP status each refusal answers with. */
30 export const TRANSFER_STATUS: Readonly<Record<Exclude<TransferActionError, "not_found">, number>> = {
31 name_taken: 409,
32 same_owner: 409,
33 move_failed: 500,
34 forbidden_owner: 403,
35 unknown_owner: 422,
36 };
37
38 export async function transferRepository(
39 app: App,
40 actor: User | undefined,
41 repo: Repo,
42 newOwner: string,
43 ): Promise<Result<Repo, TransferActionError>> {
44 if (actor === undefined || actor.kind === "agent" || !canAdmin(app.db, repo, actor)) return err("not_found");
45 const target = getUserByName(app.db, newOwner);
46 if (target === undefined || target.kind === "agent") return err("unknown_owner");
47 const ownerId = resolveOwner(app, actor, target.username);
48 if (ownerId !== target.id && !actor.isAdmin) return err("forbidden_owner");
49 const moved = await transferRepo(app.db, app.reposRoot, repo, target, actor.id);
50 if (!moved.ok) return moved;
51 storageChanged(app, repo.ownerId);
52 storageChanged(app, target.id);
53 app.bus.append({
54 kind: "repo.transferred",
55 repoId: repo.id,
56 actorId: actor.id,
57 payload: {
58 from: `${repo.ownerName}/${repo.name}`,
59 to: `${moved.value.ownerName}/${moved.value.name}`,
60 },
61 });
62 return ok(moved.value);
63 }
src/contract/openapi/routes/index.ts +1
@@ -320,6 +320,7 @@ export { route as postApiV1ReposOwnerNameRunsIdCancel } from "./postApiV1ReposOw
320320 export { route as postApiV1ReposOwnerNameRunsIdSteer } from "./postApiV1ReposOwnerNameRunsIdSteer.ts";
321321 export { route as postApiV1ReposOwnerNameSpend } from "./postApiV1ReposOwnerNameSpend.ts";
322322 export { route as postApiV1ReposOwnerNameSync } from "./postApiV1ReposOwnerNameSync.ts";
323 export { route as postApiV1ReposOwnerNameTransfer } from "./postApiV1ReposOwnerNameTransfer.ts";
323324 export { route as postApiV1ReposOwnerNameUpstreamCheck } from "./postApiV1ReposOwnerNameUpstreamCheck.ts";
324325 export { route as postApiV1Sessions } from "./postApiV1Sessions.ts";
325326 export { route as postApiV1Share } from "./postApiV1Share.ts";
src/contract/openapi/routes/postApiV1ReposOwnerNameTransfer.ts added +18
@@ -0,0 +1,18 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: `${REPO}/transfer`,
6 summary: "Move a repository to another owner, an account or an organisation the caller may create repositories under (rw scope, a repository admin's); its old web and API paths answer 308 to the new ones; answers the repository",
7 auth: true,
8 body: { newOwner: "string" },
9 refusals: {
10 not_found: "404: no such repository, or the caller is not its admin",
11 bad_new_owner: "422: newOwner is not a name",
12 unknown_owner: "422: no account or organisation has that name",
13 forbidden_owner: "403: the caller cannot create repositories under that owner",
14 name_taken: "409: the new owner already has a repository by that name",
15 same_owner: "409: the repository already belongs to that owner",
16 move_failed: "500: the repository could not be moved; nothing changed",
17 },
18 };
src/core/event_audience.ts +2
@@ -144,6 +144,8 @@ const READER_KINDS: ReadonlySet<string> = new Set([
144144 "repo.links_probed",
145145 "repo.detached",
146146 "repo.deleted",
147 // Who moved a repository from where to where (#569): its readers knew both paths.
148 "repo.transferred",
147149 "label.updated",
148150 "label.deleted",
149151 "telemetry.granted",
src/core/migration-pins/20261109150000-repo-redirect.pin added +1
@@ -0,0 +1,1 @@
1 8b91e4340037d69d32cb5f9001336fb4ce44f759f275d052df5ff1c9a2437a53
src/core/migrations/20261109150000-repo-redirect.ts added +29
@@ -0,0 +1,29 @@
1 // Where a moved repository went (#569): one row per old owner/name, naming
2 // the repository by id, so its old web and API paths answer 308 to wherever
3 // it lives now — even after a second move. Written by `transferRepo`
4 // (`core/transfer.ts`) in the same transaction as the repo row's new owner;
5 // a repository later made at the old path takes it back (the row is only
6 // read when no repository answers there). Cascades with the repository.
7 // `repo_transfer` is the audit trail: one row per move, never rewritten —
8 // who moved which repository from where to where, and when.
9 // Idempotent (#558): a second apply finds the table and changes nothing.
10 export default `
11 CREATE TABLE IF NOT EXISTS repo_redirect (
12 owner_name TEXT NOT NULL COLLATE NOCASE,
13 name TEXT NOT NULL COLLATE NOCASE,
14 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
15 moved_by TEXT NOT NULL DEFAULT '',
16 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
17 PRIMARY KEY (owner_name, name)
18 );
19 CREATE INDEX IF NOT EXISTS repo_redirect_repo ON repo_redirect(repo_id);
20 CREATE TABLE IF NOT EXISTS repo_transfer (
21 id TEXT PRIMARY KEY,
22 repo_id TEXT NOT NULL,
23 from_path TEXT NOT NULL,
24 to_path TEXT NOT NULL,
25 actor_id TEXT NOT NULL,
26 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
27 );
28 CREATE INDEX IF NOT EXISTS repo_transfer_repo ON repo_transfer(repo_id, id);
29 `;
src/core/transfer.ts added +168
@@ -0,0 +1,168 @@
1 /**
2 * Moving a repository to another owner (#569): the storage half. The bare
3 * repository is renamed on disk, then one transaction gives the repo row its
4 * new owner and records where the old path went (`repo_redirect`). When the
5 * transaction fails the directory is renamed back, so disk and rows never
6 * disagree. Everything else about a repository — its app, secrets, crew,
7 * autonomy, direction, issues and pulls — is keyed by the repo's id, so it
8 * travels with the row; an app's data lives under its app id and does not
9 * move. A fork's `alternates` file names its parent's objects by absolute
10 * path, so each fork's is rewritten to the new place.
11 */
12
13 import type { Database } from "bun:sqlite";
14 import { mkdir, readFile, rename, rmdir, writeFile } from "node:fs/promises";
15 import { dirname, join } from "node:path";
16 import { closeReader } from "../git/read.ts";
17 import { repoDir, repoDirExists } from "../git/repos.ts";
18 import { id } from "../kernel/id.ts";
19 import { err, ok, type Result } from "../kernel/result.ts";
20 import { forgetListings } from "./listings.ts";
21 import { getRepo, getRepoById, listForks, type Repo } from "./repos.ts";
22 import type { User } from "./users.ts";
23
24 export type TransferError = "name_taken" | "same_owner" | "move_failed";
25
26 /**
27 * The old owner/name pairs, held in process memory per database so the
28 * common request — under a path nothing moved from — costs no statement.
29 * Loaded on first use; `transferRepo` drops it. A pair whose row has gone
30 * (its repository deleted) only costs the one lookup that finds nothing.
31 */
32 const moved = new WeakMap<Database, Set<string>>();
33 const key = (ownerName: string, name: string): string => `${ownerName.toLowerCase()}/${name.toLowerCase()}`;
34
35 function movedPaths(db: Database): Set<string> {
36 let paths = moved.get(db);
37 if (paths === undefined) {
38 paths = new Set(
39 db
40 .query<{ owner_name: string; name: string }, []>("SELECT owner_name, name FROM repo_redirect")
41 .all()
42 .map((r) => key(r.owner_name, r.name)),
43 );
44 moved.set(db, paths);
45 }
46 return paths;
47 }
48
49 /** Where an old owner/name went, when no repository answers there now. */
50 export function redirectFor(db: Database, ownerName: string, name: string): Repo | undefined {
51 if (!movedPaths(db).has(key(ownerName, name))) return undefined;
52 const row = db
53 .query<{ repo_id: string }, [string, string]>(
54 "SELECT repo_id FROM repo_redirect WHERE owner_name = ?1 AND name = ?2",
55 )
56 .get(ownerName, name);
57 if (row === null) return undefined;
58 if (getRepo(db, ownerName, name) !== undefined) return undefined;
59 return getRepoById(db, row.repo_id);
60 }
61
62 async function rewriteAlternates(forkDir: string, from: string, to: string): Promise<void> {
63 const file = join(forkDir, "objects", "info", "alternates");
64 const text = await readFile(file, "utf8").catch(() => undefined);
65 if (text === undefined || !text.includes(from)) return;
66 await writeFile(file, text.split(from).join(to));
67 }
68
69 /**
70 * One transfer at a time in this process: the checks (name free, place
71 * free), the rename and the transaction run as one step, so two moves to
72 * the same owner/name cannot both pass the checks, and a rollback never
73 * renames back over a directory another move just put there. Moves are rare;
74 * a single queue costs nothing worth a finer lock.
75 */
76 let queue: Promise<unknown> = Promise.resolve();
77
78 export function transferRepo(
79 db: Database,
80 reposRoot: string,
81 repo: Repo,
82 target: User,
83 movedBy: string,
84 ): Promise<Result<Repo, TransferError>> {
85 const turn = queue.then(() => moveNow(db, reposRoot, repo, target, movedBy));
86 queue = turn.catch(() => undefined);
87 return turn;
88 }
89
90 async function moveNow(
91 db: Database,
92 reposRoot: string,
93 stale: Repo,
94 target: User,
95 movedBy: string,
96 ): Promise<Result<Repo, TransferError>> {
97 // The row as it is now, not as the caller read it before waiting its turn.
98 const repo = getRepoById(db, stale.id);
99 if (repo === undefined) return err("move_failed");
100 if (target.id === repo.ownerId) return err("same_owner");
101 if (getRepo(db, target.username, repo.name) !== undefined) return err("name_taken");
102 const from = repoDir(reposRoot, repo.ownerName, repo.name);
103 const to = repoDir(reposRoot, target.username, repo.name);
104 if (await repoDirExists(to)) return err("name_taken");
105 const hadDir = await repoDirExists(from);
106 if (hadDir) {
107 closeReader(from);
108 await mkdir(dirname(to), { recursive: true });
109 try {
110 await rename(from, to);
111 } catch {
112 return err("move_failed");
113 }
114 }
115 const fromPath = `${repo.ownerName}/${repo.name}`;
116 const toPath = `${target.username}/${repo.name}`;
117 try {
118 db.transaction(() => {
119 db.query("UPDATE repo SET owner_id = ?1 WHERE id = ?2").run(target.id, repo.id);
120 // The new path is a real repository now; a redirect that pointed it
121 // elsewhere would never be read, so it goes.
122 db.query("DELETE FROM repo_redirect WHERE owner_name = ?1 AND name = ?2").run(target.username, repo.name);
123 db.query(
124 `INSERT INTO repo_redirect (owner_name, name, repo_id, moved_by) VALUES (?1, ?2, ?3, ?4)
125 ON CONFLICT (owner_name, name) DO UPDATE SET repo_id = excluded.repo_id,
126 moved_by = excluded.moved_by, created_at = excluded.created_at`,
127 ).run(repo.ownerName, repo.name, repo.id, movedBy);
128 db.query(
129 "INSERT INTO repo_transfer (id, repo_id, from_path, to_path, actor_id) VALUES (?1, ?2, ?3, ?4, ?5)",
130 ).run(id(), repo.id, fromPath, toPath, movedBy);
131 })();
132 moved.delete(db);
133 } catch {
134 if (hadDir) await rename(to, from).catch(() => undefined);
135 return err("move_failed");
136 }
137 if (hadDir) {
138 // The old owner's directory may be empty now; leave it if not.
139 await rmdir(dirname(from)).catch(() => undefined);
140 const objectsFrom = join(from, "objects");
141 const objectsTo = join(to, "objects");
142 for (const fork of listForks(db, repo.id)) {
143 await rewriteAlternates(repoDir(reposRoot, fork.ownerName, fork.name), objectsFrom, objectsTo).catch(
144 () => undefined,
145 );
146 }
147 }
148 forgetListings(db);
149 const after = getRepoById(db, repo.id);
150 return after === undefined ? err("move_failed") : ok(after);
151 }
152
153 export interface TransferRecord {
154 readonly from: string;
155 readonly to: string;
156 readonly actorId: string;
157 readonly at: string;
158 }
159
160 /** The audit trail of a repository's moves, oldest first. */
161 export function transfersOf(db: Database, repoId: string): TransferRecord[] {
162 return db
163 .query<{ from_path: string; to_path: string; actor_id: string; created_at: string }, [string]>(
164 "SELECT from_path, to_path, actor_id, created_at FROM repo_transfer WHERE repo_id = ?1 ORDER BY id",
165 )
166 .all(repoId)
167 .map((r) => ({ from: r.from_path, to: r.to_path, actorId: r.actor_id, at: r.created_at }));
168 }
src/http/api.ts +19
@@ -117,6 +117,7 @@ import {
117117 resolveOwner,
118118 updateRepoSettings,
119119 } from "../actions/repos.ts";
120 import { TRANSFER_SAID, TRANSFER_STATUS, transferRepository } from "../actions/transfer.ts";
120121 import {
121122 createToken,
122123 HUMAN_CHECK_MESSAGE,
@@ -1672,6 +1673,24 @@ export function apiRoutes(router: Router<Handler>): void {
16721673 return json({}, 200);
16731674 });
16741675
1676 // Move a repository to another owner (#569): its old paths answer 308.
1677 router.add("POST", "/api/v1/repos/:owner/:name/transfer", async (ctx) => {
1678 const denied = requireScope(ctx, "rw");
1679 if (denied) return denied;
1680 const repo = loadRepo(ctx);
1681 if (!repo) return notFound();
1682 const input = await body(ctx.request);
1683 const newOwner = input.newOwner;
1684 if (typeof newOwner !== "string" || newOwner.length === 0 || newOwner.length > 64)
1685 return apiError("bad_new_owner", 422, "newOwner is an account or organisation name");
1686 const moved = await transferRepository(ctx.app, ctx.auth.user, repo, newOwner);
1687 if (!moved.ok) {
1688 if (moved.error === "not_found") return notFound();
1689 return apiError(moved.error, TRANSFER_STATUS[moved.error], TRANSFER_SAID[moved.error]);
1690 }
1691 return json(repoJson(ctx, moved.value));
1692 });
1693
16751694 router.add("GET", "/api/v1/repos/:owner/:name/events", (ctx) => {
16761695 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
16771696 const repo = loadRepo(ctx);
src/http/moved.ts added +36
@@ -0,0 +1,36 @@
1 /**
2 * A moved repository's old paths (#569). A web or API request under an
3 * owner/name that a transfer left behind answers 308 to the same path under
4 * the repository's new owner — a GET and a mutation alike, so a write is never
5 * applied silently to the new place: the client re-sends it, knowing where.
6 * Only a caller who may read the repository learns where it went; anyone else
7 * keeps the 404 a missing path has always answered.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { canRead } from "../core/repos.ts";
12 import { redirectFor } from "../core/transfer.ts";
13 import type { User } from "../core/users.ts";
14 import { isSafeName } from "../git/guard.ts";
15
16 const API = "/api/v1/repos/";
17
18 /** The owner/name a path names, its prefix and the rest; undefined when it names none. */
19 function repoPath(pathname: string): { prefix: string; owner: string; name: string; rest: string } | undefined {
20 const prefix = pathname.startsWith(API) ? API : "/";
21 const parts = pathname.slice(prefix.length).split("/");
22 if (parts.length < 2) return undefined;
23 const [owner, name] = parts as [string, string];
24 if (!isSafeName(owner) || !isSafeName(name)) return undefined;
25 return { prefix, owner, name, rest: parts.slice(2).join("/") };
26 }
27
28 export function movedResponse(db: Database, url: URL, user: User | undefined): Response | undefined {
29 const at = repoPath(url.pathname);
30 if (at === undefined) return undefined;
31 const repo = redirectFor(db, at.owner, at.name);
32 if (repo === undefined || !canRead(db, repo, user)) return undefined;
33 const rest = at.rest === "" && !url.pathname.endsWith("/") ? "" : `/${at.rest}`;
34 const location = `${at.prefix}${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}${rest}${url.search}`;
35 return new Response(null, { status: 308, headers: { location } });
36 }
src/http/server.ts +5
@@ -443,6 +443,7 @@ import { pageParamRefused } from "./cursor_lists.ts";
443443 import { inNamespace } from "../core/proposal_token.ts";
444444 import { proposalTokenMint, proposalTokenRevoke } from "../web/pages/proposal-tokens.ts";
445445 import { adminGate } from "./admin_gate.ts";
446 import { movedResponse } from "./moved.ts";
446447 import { originGate } from "./origin_gate.ts";
447448 import { proposalGate } from "./proposal_gate.ts";
448449 import { runGate } from "./run_gate.ts";
@@ -2816,6 +2817,10 @@ async function answerGet(
28162817 const adminRefusal = adminGate(app, request, url, auth);
28172818 if (adminRefusal !== undefined) return adminRefusal;
28182819
2820 // A transferred repository's old path answers 308 to its new one (#569).
2821 const moved = movedResponse(app.db, url, auth.user);
2822 if (moved !== undefined) return moved;
2823
28192824 // The two things standing between one runaway agent and everybody
28202825 // else's latency, applied before a handler runs and after auth is
28212826 // known — a limit needs an identity, and a replay needs a credential.
src/tui/screens/transferrepo.ts added +57
@@ -0,0 +1,57 @@
1 /**
2 * Move this repository to another owner, from a repository screen's `:`
3 * palette (#569): the terminal's door to `POST …/transfer`. The new owner's
4 * name is typed; the move refuses what the API refuses, in the same words.
5 * Only a person who administers the repository is offered it.
6 */
7
8 import { TRANSFER_SAID, transferRepository } from "../../actions/transfer.ts";
9 import { canAdmin, type Repo } from "../../core/repos.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import type { Command } from "../app/palette.ts";
12 import type { Session } from "../session.ts";
13 import { promptScreen } from "./common.ts";
14
15 export const TRANSFER_REPO = "Move this repository to another owner";
16
17 /** The prompt floats over whatever is already drawn; this is the empty backdrop it asks for. */
18 const backdrop: Screen = {
19 title: TRANSFER_REPO,
20 keys: [],
21 render() {},
22 key: () => false,
23 };
24
25 export function transferRepoCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
26 if (repo === undefined || f.user === undefined || f.user.kind !== "user") return [];
27 if (!canAdmin(f.db, repo, f.user)) return [];
28 const full = `${repo.ownerName}/${repo.name}`;
29 return [
30 {
31 id: "transfer-repository",
32 title: `${full} — ${TRANSFER_REPO}`,
33 hint: "old links answer with the new place",
34 run: () =>
35 ctx.push(
36 promptScreen(ctx, backdrop, {
37 title: TRANSFER_REPO,
38 label: `Moves ${full} with its issues, pulls, app and crew. New owner:`,
39 placeholder: "an account or organisation",
40 onSubmit: (typed) => {
41 const newOwner = typed.trim();
42 if (newOwner === "") return "Type the new owner's name.";
43 return (async () => {
44 const moved = await transferRepository(f.app, f.user, repo, newOwner);
45 if (!moved.ok) {
46 ctx.toast(moved.error === "not_found" ? "no such repository" : TRANSFER_SAID[moved.error], "danger");
47 return;
48 }
49 ctx.toast(`moved ${full} to ${moved.value.ownerName}/${moved.value.name}`, "success");
50 ctx.leaveRepo(repo.id);
51 })();
52 },
53 }),
54 ),
55 },
56 ];
57 }
src/tui/screens/workspace.ts +2
@@ -131,6 +131,7 @@ import { workingCommands } from "./working.ts";
131131 import { forgeItCommands } from "./forge_it.ts";
132132 import { makeItYoursScreen, yoursCommands } from "./yours.ts";
133133 import { deleteRepoCommands } from "./deleterepo.ts";
134 import { transferRepoCommands } from "./transferrepo.ts";
134135 import { grantsCommands } from "./grants.ts";
135136 import { visionCommands } from "./vision.ts";
136137 import { buildsCommands } from "./builds.ts";
@@ -1448,6 +1449,7 @@ export function workspaceScreen(
14481449 ...reportsCommands(f, ctx, repo),
14491450 ...noteCommands(f, ctx, () => screen, repo),
14501451 ...yoursCommands(f, ctx, repo),
1452 ...transferRepoCommands(f, ctx, repo),
14511453 ...deleteRepoCommands(f, ctx, repo),
14521454 ...grantsCommands(f, ctx, repo),
14531455 ...forgeItCommands(f, ctx, repo, () => screen),
test/org-owners.test.ts +9 −1
@@ -21,6 +21,7 @@ import {
2121 import { createApp, type App } from "../src/app.ts";
2222 import { listTeams, OWNERS_TEAM, type Team } from "../src/core/orgs.ts";
2323 import { migrations } from "../src/core/schema.ts";
24 import { idFloor } from "../src/kernel/id.ts";
2425 import { createToken } from "../src/core/tokens.ts";
2526 import { createUser, getUserByName, type User } from "../src/core/users.ts";
2627 import { serve, type Servers } from "../src/http/server.ts";
@@ -217,11 +218,18 @@ describe("an organization always keeps a person who owns it", () => {
217218 ]);
218219 const ddl = migrations.find((m) => m.includes("an agent never owns"));
219220 expect(ddl).toBeString();
220 const before = (
221 const last = (
221222 app.db.query("SELECT id FROM event ORDER BY id DESC LIMIT 1").get() as {
222223 id: string;
223224 }
224225 ).id;
226 // The migration stamps its id from SQLite's wall clock; `id()` is
227 // monotonic per process, so a suite earlier in the same process that
228 // moved `Date.now` ahead (`api-forge-view.test.ts`) leaves it ahead of
229 // the wall. The bound is the last event or the wall clock, whichever is
230 // older: with an honest clock that is the last event, as before.
231 const wall = idFloor(Date.now());
232 const before = last < wall ? last : wall;
225233 app.db.exec(ddl!);
226234 expect(members(owners)).not.toContain(bot.id);
227235 expect(members(owners)).toContain(alice.id);
test/repo-transfer.test.ts added +267
@@ -0,0 +1,267 @@
1 /**
2 * Moving a repository to another owner (#569): `POST …/transfer {newOwner}`
3 * moves the row and the bare repository, and the old web and API paths answer
4 * 308 to the new ones — GETs and mutations alike. The refusals come first.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { existsSync } from "node:fs";
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 { openIssue } from "../src/actions/issues.ts";
14 import { createOrg } from "../src/actions/orgs.ts";
15 import { openPull } from "../src/actions/pulls.ts";
16 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
17 import { createApp as createAppRow, getAppByRepo } from "../src/core/apps.ts";
18 import { migrationFiles } from "../src/core/schema.ts";
19 import { createRepo as makeRepo, forkRepo, getRepo, getRepoById, type Repo } from "../src/core/repos.ts";
20 import { transferRepo, transfersOf } from "../src/core/transfer.ts";
21 import { getUserByName } from "../src/core/users.ts";
22 import { commitFiles } from "../src/git/write.ts";
23 import { readFileSync } from "node:fs";
24 import { createToken } from "../src/core/tokens.ts";
25 import { createUser, type User } from "../src/core/users.ts";
26 import { repoDir } from "../src/git/repos.ts";
27 import { serve, type Servers } from "../src/http/server.ts";
28 import { quietLogs } from "./helpers/globals.ts";
29
30 quietLogs();
31
32 const PASSWORD = "a-long-transfer-passphrase";
33
34 describe("transferring a repository", () => {
35 let dataDir = "";
36 let app: App;
37 let servers: Servers;
38 let alice: User;
39 let bob: User;
40 let aliceRw = "";
41 let aliceRo = "";
42 let bobRw = "";
43 let repo: Repo;
44 let issueNumber = 0;
45 let pullNumber = 0;
46
47 const call = (path: string, token: string, method = "GET", payload?: unknown) =>
48 fetch(`${servers.url}${path}`, {
49 method,
50 redirect: "manual",
51 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
52 ...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
53 });
54
55 beforeAll(async () => {
56 dataDir = await mkdtemp(join(tmpdir(), "forge-transfer-"));
57 app = createApp({ dataDir, port: 0 });
58 servers = await serve(app);
59 const made = async (username: string, admin: boolean) => {
60 const u = await createUser(app.db, {
61 origin: "seed",
62 trusted: true,
63 username,
64 email: `${username}@forge.local`,
65 password: PASSWORD,
66 adminIfFirst: admin,
67 });
68 if (!u.ok) throw new Error(u.error);
69 return u.value;
70 };
71 alice = await made("alice", true);
72 bob = await made("bob", false);
73 aliceRw = createToken(app.db, alice.id, "rw", "rw").token;
74 aliceRo = createToken(app.db, alice.id, "ro", "ro").token;
75 bobRw = createToken(app.db, bob.id, "rw", "rw").token;
76 const org = createOrg(app, alice, "lab");
77 if (!org.ok) throw new Error(org.error);
78 const r = await createRepo(app, alice, { name: "notes", visibility: "public" });
79 if (!r.ok) throw new Error(String(r.error));
80 repo = r.value;
81 await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# notes\n" }] });
82 await commitToBranch(app, alice, repo, {
83 branch: "topic",
84 base: "main",
85 message: "topic",
86 files: [{ path: "T.md", content: "t\n" }],
87 });
88 const pull = await openPull(app, alice, repo, { title: "Topic", body: "", baseBranch: "main", headBranch: "topic" });
89 if (!pull.ok) throw new Error(String(pull.error));
90 pullNumber = pull.value.number;
91 const issue = openIssue(app, alice, repo, { title: "Bookmarked" });
92 if (!issue.ok) throw new Error(String(issue.error));
93 issueNumber = issue.value.number;
94 const appRow = createAppRow(app.db, { repoId: repo.id, slug: "notes", access: "public" });
95 if (!appRow.ok) throw new Error(appRow.error);
96 // A second "notes" under bob, so moving there is refused.
97 const clash = await makeRepo(app.db, app.reposRoot, { ownerId: bob.id, name: "notes", visibility: "public" });
98 if (!clash.ok) throw new Error(String(clash.error));
99 }, 30_000);
100
101 afterAll(async () => {
102 await servers.stop();
103 await rm(dataDir, { recursive: true, force: true });
104 });
105
106 test("a read-only token, a stranger and a taken name are refused, and nothing moves", async () => {
107 expect((await call("/api/v1/repos/alice/notes/transfer", aliceRo, "POST", { newOwner: "lab" })).status).toBe(403);
108 expect((await call("/api/v1/repos/alice/notes/transfer", bobRw, "POST", { newOwner: "bob" })).status).toBe(404);
109 const taken = await call("/api/v1/repos/alice/notes/transfer", aliceRw, "POST", { newOwner: "bob" });
110 expect(taken.status).toBe(409);
111 expect(((await taken.json()) as { error: string }).error).toBe("name_taken");
112 expect((await call("/api/v1/repos/alice/notes/transfer", aliceRw, "POST", { newOwner: 7 })).status).toBe(422);
113 expect((await call("/api/v1/repos/alice/notes/transfer", aliceRw, "POST", { newOwner: "nobody" })).status).toBe(
114 422,
115 );
116 expect(getRepo(app.db, "alice", "notes")?.id).toBe(repo.id);
117 });
118
119 test("bob cannot move his own repository under an org he is not in", async () => {
120 const res = await call("/api/v1/repos/bob/notes/transfer", bobRw, "POST", { newOwner: "lab" });
121 expect(res.status).toBe(403);
122 });
123
124 test("the move takes the row, the bare repository and the app along", async () => {
125 const res = await call("/api/v1/repos/alice/notes/transfer", aliceRw, "POST", { newOwner: "lab" });
126 expect(res.status).toBe(200);
127 expect(getRepo(app.db, "lab", "notes")?.id).toBe(repo.id);
128 expect(getRepo(app.db, "alice", "notes")).toBeUndefined();
129 expect(existsSync(join(repoDir(app.reposRoot, "lab", "notes"), "HEAD"))).toBe(true);
130 expect(existsSync(repoDir(app.reposRoot, "alice", "notes"))).toBe(false);
131 expect(getAppByRepo(app.db, repo.id)?.slug).toBe("notes");
132 const event = app.db
133 .query<{ actor_id: string; payload: string }, [string]>(
134 "SELECT actor_id, payload FROM event WHERE kind = 'repo.transferred' AND repo_id = ?1",
135 )
136 .get(repo.id);
137 expect(event?.actor_id).toBe(alice.id);
138 expect(JSON.parse(event!.payload)).toMatchObject({ from: "alice/notes", to: "lab/notes" });
139 });
140
141 test("the old page, API path, issue and pull answer 308 to the new ones", async () => {
142 const page = await call("/alice/notes", aliceRw);
143 expect(page.status).toBe(308);
144 expect(page.headers.get("location")).toBe("/lab/notes");
145 const api = await call("/api/v1/repos/alice/notes?x=1", aliceRw);
146 expect(api.status).toBe(308);
147 expect(api.headers.get("location")).toBe("/api/v1/repos/lab/notes?x=1");
148 const issue = await call(`/alice/notes/issues/${issueNumber}`, aliceRw);
149 expect(issue.status).toBe(308);
150 expect(issue.headers.get("location")).toBe(`/lab/notes/issues/${issueNumber}`);
151 const pull = await call(`/api/v1/repos/alice/notes/pulls/${pullNumber}`, aliceRw);
152 expect(pull.status).toBe(308);
153 expect(pull.headers.get("location")).toBe(`/api/v1/repos/lab/notes/pulls/${pullNumber}`);
154 // The new paths answer.
155 expect((await call(`/api/v1/repos/lab/notes/pulls/${pullNumber}`, aliceRw)).status).toBe(200);
156 expect((await call(`/api/v1/repos/lab/notes/issues/${issueNumber}`, aliceRw)).status).toBe(200);
157 });
158
159 test("a mutation under the old path is not applied: it answers 308", async () => {
160 const res = await call("/api/v1/repos/alice/notes/issues", aliceRw, "POST", { title: "lost?" });
161 expect(res.status).toBe(308);
162 expect(res.headers.get("location")).toBe("/api/v1/repos/alice/notes/issues".replace("alice", "lab"));
163 const count = app.db
164 .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1 AND title = 'lost?'")
165 .get(repo.id);
166 expect(count?.n).toBe(0);
167 });
168
169 test("the move leaves an audit row: who moved what from where to where", () => {
170 expect(transfersOf(app.db, repo.id)).toEqual([
171 expect.objectContaining({ from: "alice/notes", to: "lab/notes", actorId: alice.id }),
172 ]);
173 });
174
175 test("a caller who cannot read the repository gets 404 from its old path, not where it went", async () => {
176 const hidden = await createRepo(app, alice, { name: "hidden", visibility: "private" });
177 if (!hidden.ok) throw new Error(String(hidden.error));
178 expect((await call("/api/v1/repos/alice/hidden/transfer", aliceRw, "POST", { newOwner: "lab" })).status).toBe(200);
179 expect((await call("/api/v1/repos/alice/hidden", aliceRw)).status).toBe(308);
180 for (const path of ["/api/v1/repos/alice/hidden", "/alice/hidden"]) {
181 const res = await call(path, bobRw);
182 expect(res.status).toBe(404);
183 expect(res.headers.get("location")).toBeNull();
184 }
185 });
186
187 test("moved a second time, the first old path follows to the newest owner", async () => {
188 const lab2 = createOrg(app, alice, "lab2");
189 if (!lab2.ok) throw new Error(lab2.error);
190 expect((await call("/api/v1/repos/lab/notes/transfer", aliceRw, "POST", { newOwner: "lab2" })).status).toBe(200);
191 for (const old of ["alice", "lab"]) {
192 const res = await call(`/${old}/notes/issues/${issueNumber}`, aliceRw);
193 expect(res.status).toBe(308);
194 expect(res.headers.get("location")).toBe(`/lab2/notes/issues/${issueNumber}`);
195 }
196 expect(transfersOf(app.db, repo.id).map((t) => t.to)).toEqual(["lab/notes", "lab2/notes"]);
197 });
198
199 test("a fork's borrowed objects follow its parent to the new place", async () => {
200 const made = await makeRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "parent", visibility: "public" });
201 if (!made.ok) throw new Error(made.error);
202 const c = await commitFiles(repoDir(app.reposRoot, "alice", "parent"), {
203 branch: "main",
204 message: "seed",
205 files: [{ path: "a.txt", content: "a" }],
206 author: { name: "T", email: "[email protected]" },
207 });
208 if (!c.ok) throw new Error("commit failed");
209 const fork = await forkRepo(app.db, app.reposRoot, made.value, bob.id);
210 if (!fork.ok) throw new Error(fork.error);
211 const forkDir = repoDir(app.reposRoot, "bob", fork.value.name);
212 const moved = await transferRepo(app.db, app.reposRoot, made.value, getUserByName(app.db, "lab")!, alice.id);
213 expect(moved.ok).toBe(true);
214 const alternates = readFileSync(join(forkDir, "objects", "info", "alternates"), "utf8");
215 expect(alternates).toContain(join(repoDir(app.reposRoot, "lab", "parent"), "objects"));
216 expect(alternates).not.toContain(repoDir(app.reposRoot, "alice", "parent"));
217 const fsck = Bun.spawnSync(["git", "cat-file", "-e", `${c.value.commitOid}^{commit}`], { cwd: forkDir });
218 expect(fsck.exitCode).toBe(0);
219 });
220
221 test("when the transaction fails the directory is renamed back and nothing changes", async () => {
222 const made = await makeRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "stays", visibility: "public" });
223 if (!made.ok) throw new Error(made.error);
224 app.db.exec(
225 "CREATE TRIGGER refuse_transfer BEFORE INSERT ON repo_transfer BEGIN SELECT RAISE(ABORT, 'refused'); END",
226 );
227 try {
228 const moved = await transferRepo(app.db, app.reposRoot, made.value, getUserByName(app.db, "lab")!, alice.id);
229 expect(moved.ok ? "ok" : moved.error).toBe("move_failed");
230 } finally {
231 app.db.exec("DROP TRIGGER refuse_transfer");
232 }
233 expect(getRepoById(app.db, made.value.id)?.ownerName).toBe("alice");
234 expect(existsSync(join(repoDir(app.reposRoot, "alice", "stays"), "HEAD"))).toBe(true);
235 expect(existsSync(repoDir(app.reposRoot, "lab", "stays"))).toBe(false);
236 expect(transfersOf(app.db, made.value.id)).toEqual([]);
237 expect((await call("/api/v1/repos/lab/stays", aliceRw)).status).toBe(404);
238 });
239
240 test("two moves to the same owner and name at once: one lands, the other is refused", async () => {
241 const twins: Repo[] = [];
242 for (const owner of [alice, bob]) {
243 const made = await makeRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "twin", visibility: "public" });
244 if (!made.ok) throw new Error(made.error);
245 twins.push(made.value);
246 }
247 const lab = getUserByName(app.db, "lab")!;
248 const results = await Promise.all(twins.map((t) => transferRepo(app.db, app.reposRoot, t, lab, alice.id)));
249 expect(results.map((r) => (r.ok ? "ok" : r.error)).sort()).toEqual(["name_taken", "ok"]);
250 const landed = results.findIndex((r) => r.ok);
251 const other = twins[1 - landed]!;
252 expect(getRepoById(app.db, other.id)?.ownerId).toBe(other.ownerId);
253 expect(existsSync(join(repoDir(app.reposRoot, other.ownerName, "twin"), "HEAD"))).toBe(true);
254 expect(getRepo(app.db, "lab", "twin")?.id).toBe(twins[landed]!.id);
255 });
256
257 test("a repository made again at the old path takes it back", async () => {
258 const again = await createRepo(app, alice, { name: "notes", visibility: "public" });
259 expect(again.ok).toBe(true);
260 expect((await call("/api/v1/repos/alice/notes", aliceRw)).status).toBe(200);
261 });
262
263 test("the migration applies twice without complaint", () => {
264 const sql = migrationFiles.find((m) => m.name.includes("repo-redirect"))!.sql;
265 expect(() => app.db.exec(sql)).not.toThrow();
266 });
267 });
test/tui-transfer-repo.test.ts added +157
@@ -0,0 +1,157 @@
1 /**
2 * Moving a repository from the terminal's `:` palette (#569): an admin types
3 * the new owner and the repository moves; a refusal says why in the API's
4 * words; a writer who is not an admin, and an agent, are never offered it.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts";
13 import { canAdmin, canWrite, getRepoById, type Repo } from "../src/core/repos.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import type { Screen } from "../src/tui/app/app.ts";
16 import { style } from "../src/tui/term/style.ts";
17 import { createForgeApp } from "../src/tui/forge.ts";
18 import { transferRepoCommands } from "../src/tui/screens/transferrepo.ts";
19 import { getRepo } from "../src/core/repos.ts";
20 import { repoScreen } from "../src/tui/screens/repo.ts";
21 import { local } from "../src/tui/screens/where.ts";
22 import { headlessTerminal } from "../src/tui/term/terminal.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { settleApp } from "./helpers/tui.ts";
25 import { proveAll } from "./helpers/verified.ts";
26
27 quietLogs();
28
29 let dir: string;
30 let app: App;
31 let alice: User;
32 let bob: User;
33 let bot: User;
34
35 const KEYS: Record<string, string> = { enter: "\r" };
36
37 /** A screen that says it is beneath the repository's, so a test can see where the unwinding landed. */
38 const below: Screen = {
39 title: "below",
40 keys: [],
41 render: (area, s) => void s.text(area.x, area.y, "BELOW-THE-REPO", style(0, 0), area.x + area.w),
42 key: () => false,
43 };
44
45 function open(user: User, at: Repo, nested = false) {
46 const terminal = headlessTerminal(120, 40);
47 const { app: tui, session } = createForgeApp(
48 app,
49 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
50 { terminal, exitHooks: false },
51 );
52 tui.start();
53 if (nested) tui.ctx.push(below);
54 tui.ctx.push(repoScreen(session, tui.ctx, local(session, at)));
55 return {
56 session,
57 ctx: tui.ctx,
58 text: () => tui.screen.toText(),
59 feed: async (...keys: string[]) => {
60 for (const k of keys) {
61 terminal.feed(KEYS[k] ?? k);
62 await settleApp(tui);
63 }
64 },
65 stop: () => tui.stop?.(),
66 };
67 }
68
69 /** Open the delete prompt from the palette once its row has drawn. */
70 async function prompt(h: ReturnType<typeof open>): Promise<void> {
71 await h.feed(":", ..."Move this");
72 for (let i = 0; i < 100 && !h.text().includes("old links answer"); i++)
73 await new Promise<void>((r) => setTimeout(r, 10)), await h.feed();
74 if (!h.text().includes("old links answer")) throw new Error(`never came: still waiting while !h.text().includes("old links answer")`);
75 await h.feed("enter");
76 }
77
78 async function mkRepo(owner: string | undefined, name: string): Promise<Repo> {
79 const made = await createRepo(app, alice, { ...(owner ? { owner } : {}), name, visibility: "public" });
80 if (!made.ok) throw new Error(String(made.error));
81 return made.value;
82 }
83
84 beforeAll(async () => {
85 dir = await mkdtemp(join(tmpdir(), "forge-tui-transfer-repo-"));
86 app = createApp({ dataDir: dir, port: 0 });
87 const mk = async (username: string, first = false) => {
88 const r = await createUser(app.db, {
89 origin: "seed",
90 trusted: true,
91 username,
92 email: `${username}@forge.local`,
93 password: "a-good-passphrase",
94 adminIfFirst: first,
95 });
96 if (!r.ok) throw new Error(r.error);
97 return r.value;
98 };
99 alice = await mk("alice", true);
100 bob = await mk("bob");
101 bot = await mk("crewbot1");
102 // An agent that is also a forge admin: canAdmin accepts it, so only the kind check keeps it out.
103 app.db.query("UPDATE user SET is_agent = 1, is_admin = 1 WHERE id = ?1").run(bot.id);
104 bot = { ...bot, kind: "agent", isAdmin: true };
105 proveAll(app);
106 });
107 afterAll(async () => {
108 app.db.close();
109 await rm(dir, { recursive: true, force: true });
110 });
111
112 describe("move a repository in the terminal", () => {
113 test("an admin who types an org's name moves the repository there", async () => {
114 const org = createOrg(app.db, "lab", alice.id);
115 if (!org.ok) throw new Error(org.error);
116 const repo = await mkRepo(undefined, "wander");
117 const h = open(alice, repo);
118 await prompt(h);
119 expect(h.text()).toContain("alice/wander");
120 await h.feed(..."lab", "enter");
121 expect(getRepoById(app.db, repo.id)?.ownerName).toBe("lab");
122 expect(h.text()).toContain("moved alice/wander to lab/wander");
123 h.stop();
124 });
125
126 test("a refusal is said in the API's words and the repository stays", async () => {
127 await mkRepo("lab", "taken");
128 const repo = await mkRepo(undefined, "taken");
129 const h = open(alice, repo);
130 await prompt(h);
131 await h.feed(..."lab", "enter");
132 expect(h.text()).toContain("already has a repository by that name");
133 expect(getRepo(app.db, "alice", "taken")?.id).toBe(repo.id);
134 h.stop();
135 });
136
137 test("a writer who is not an admin, and an agent, are not offered it", async () => {
138 const org = createOrg(app.db, "acme", alice.id);
139 if (!org.ok) throw new Error(org.error);
140 const devs = createTeam(app.db, org.value.id, "devs", "write");
141 if (!devs.ok) throw new Error(devs.error);
142 addTeamMember(app.db, devs.value, bob.id);
143 const shop = await mkRepo("acme", "shop");
144 expect(canWrite(app.db, shop, bob)).toBe(true);
145 expect(canAdmin(app.db, shop, bot)).toBe(true);
146 for (const who of [bob, bot]) {
147 const h = open(who, shop);
148 expect(transferRepoCommands(h.session, h.ctx, shop)).toEqual([]);
149 h.stop();
150 }
151 const h = open(alice, shop);
152 expect(transferRepoCommands(h.session, h.ctx, shop).map((c) => c.title)).toEqual([
153 "acme/shop — Move this repository to another owner",
154 ]);
155 h.stop();
156 });
157 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs