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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/569-repo-transfer-step-one-post-transfer-new → main 4 commits fork
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 | ||
| 15 | 15 | A new `/settings…` page route goes in `src/http/settings_routes.ts` (`settingsRoutes`), not `src/http/server.ts`. |
| 16 | 16 | |
| 17 | 17 | 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 | ||
| 320 | 320 | export { route as postApiV1ReposOwnerNameRunsIdSteer } from "./postApiV1ReposOwnerNameRunsIdSteer.ts"; |
| 321 | 321 | export { route as postApiV1ReposOwnerNameSpend } from "./postApiV1ReposOwnerNameSpend.ts"; |
| 322 | 322 | export { route as postApiV1ReposOwnerNameSync } from "./postApiV1ReposOwnerNameSync.ts"; |
| 323 | export { route as postApiV1ReposOwnerNameTransfer } from "./postApiV1ReposOwnerNameTransfer.ts"; | |
| 323 | 324 | export { route as postApiV1ReposOwnerNameUpstreamCheck } from "./postApiV1ReposOwnerNameUpstreamCheck.ts"; |
| 324 | 325 | export { route as postApiV1Sessions } from "./postApiV1Sessions.ts"; |
| 325 | 326 | 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([ | ||
| 144 | 144 | "repo.links_probed", |
| 145 | 145 | "repo.detached", |
| 146 | 146 | "repo.deleted", |
| 147 | // Who moved a repository from where to where (#569): its readers knew both paths. | |
| 148 | "repo.transferred", | |
| 147 | 149 | "label.updated", |
| 148 | 150 | "label.deleted", |
| 149 | 151 | "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 { | ||
| 117 | 117 | resolveOwner, |
| 118 | 118 | updateRepoSettings, |
| 119 | 119 | } from "../actions/repos.ts"; |
| 120 | import { TRANSFER_SAID, TRANSFER_STATUS, transferRepository } from "../actions/transfer.ts"; | |
| 120 | 121 | import { |
| 121 | 122 | createToken, |
| 122 | 123 | HUMAN_CHECK_MESSAGE, |
| @@ -1672,6 +1673,24 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1672 | 1673 | return json({}, 200); |
| 1673 | 1674 | }); |
| 1674 | 1675 | |
| 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 | ||
| 1675 | 1694 | router.add("GET", "/api/v1/repos/:owner/:name/events", (ctx) => { |
| 1676 | 1695 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 1677 | 1696 | 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"; | ||
| 443 | 443 | import { inNamespace } from "../core/proposal_token.ts"; |
| 444 | 444 | import { proposalTokenMint, proposalTokenRevoke } from "../web/pages/proposal-tokens.ts"; |
| 445 | 445 | import { adminGate } from "./admin_gate.ts"; |
| 446 | import { movedResponse } from "./moved.ts"; | |
| 446 | 447 | import { originGate } from "./origin_gate.ts"; |
| 447 | 448 | import { proposalGate } from "./proposal_gate.ts"; |
| 448 | 449 | import { runGate } from "./run_gate.ts"; |
| @@ -2816,6 +2817,10 @@ async function answerGet( | ||
| 2816 | 2817 | const adminRefusal = adminGate(app, request, url, auth); |
| 2817 | 2818 | if (adminRefusal !== undefined) return adminRefusal; |
| 2818 | 2819 | |
| 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 | ||
| 2819 | 2824 | // The two things standing between one runaway agent and everybody |
| 2820 | 2825 | // else's latency, applied before a handler runs and after auth is |
| 2821 | 2826 | // 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"; | ||
| 131 | 131 | import { forgeItCommands } from "./forge_it.ts"; |
| 132 | 132 | import { makeItYoursScreen, yoursCommands } from "./yours.ts"; |
| 133 | 133 | import { deleteRepoCommands } from "./deleterepo.ts"; |
| 134 | import { transferRepoCommands } from "./transferrepo.ts"; | |
| 134 | 135 | import { grantsCommands } from "./grants.ts"; |
| 135 | 136 | import { visionCommands } from "./vision.ts"; |
| 136 | 137 | import { buildsCommands } from "./builds.ts"; |
| @@ -1448,6 +1449,7 @@ export function workspaceScreen( | ||
| 1448 | 1449 | ...reportsCommands(f, ctx, repo), |
| 1449 | 1450 | ...noteCommands(f, ctx, () => screen, repo), |
| 1450 | 1451 | ...yoursCommands(f, ctx, repo), |
| 1452 | ...transferRepoCommands(f, ctx, repo), | |
| 1451 | 1453 | ...deleteRepoCommands(f, ctx, repo), |
| 1452 | 1454 | ...grantsCommands(f, ctx, repo), |
| 1453 | 1455 | ...forgeItCommands(f, ctx, repo, () => screen), |
test/org-owners.test.ts +9 −1
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | import { createApp, type App } from "../src/app.ts"; |
| 22 | 22 | import { listTeams, OWNERS_TEAM, type Team } from "../src/core/orgs.ts"; |
| 23 | 23 | import { migrations } from "../src/core/schema.ts"; |
| 24 | import { idFloor } from "../src/kernel/id.ts"; | |
| 24 | 25 | import { createToken } from "../src/core/tokens.ts"; |
| 25 | 26 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 26 | 27 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -217,11 +218,18 @@ describe("an organization always keeps a person who owns it", () => { | ||
| 217 | 218 | ]); |
| 218 | 219 | const ddl = migrations.find((m) => m.includes("an agent never owns")); |
| 219 | 220 | expect(ddl).toBeString(); |
| 220 | const before = ( | |
| 221 | const last = ( | |
| 221 | 222 | app.db.query("SELECT id FROM event ORDER BY id DESC LIMIT 1").get() as { |
| 222 | 223 | id: string; |
| 223 | 224 | } |
| 224 | 225 | ).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; | |
| 225 | 233 | app.db.exec(ddl!); |
| 226 | 234 | expect(members(owners)).not.toContain(bot.id); |
| 227 | 235 | 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 | }); |