Public: anyone can read this; the crew can change it.
A repository can be renamed, and its old address keeps working #12156
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12154-a-repository-can-be-renamed-and-its-old → main 7 commits fork
18 files
changed +566
−42
docs/MCP.md +1 −1
| @@ -183,7 +183,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 183 | 183 | | `releases` | — | Kept releases, newest first | 298 | |
| 184 | 184 | | `repo` | `open`, `add`, `remove`, `close` | One repository you can read, as GET /api/v1/repos/:owner/:name | 383 | |
| 185 | 185 | | `repo_labels` | — | Repo labels: list (default), create, update, remove (admin token) | 351 | |
| 186 | | `repo_settings` | — | Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams | 444 | | |
| 186 | | `repo_settings` | — | Read, change, rename (name) or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams | 484 | | |
| 187 | 187 | | `reports` | `accept`, `dismiss` | Outsiders' reports; `id`+`action`: accept or dismiss | 355 | |
| 188 | 188 | | `restore_drill` | — | Drill-restore the newest snapshot | 216 | |
| 189 | 189 | | `returns` | — | What crew tokens bought in `days` (1–90, default 7): dollars, runs, landed, cost/change | 284 | |
docs/features/repo-transfer.md +15
| @@ -67,3 +67,18 @@ arriving by an old link. The terminal says the same on the About card (`I` on | ||
| 67 | 67 | the repository's screen) as a "moved from" row. Both read the newest |
| 68 | 68 | `repo_transfer` row through `recentMove` in `src/core/transfer.ts`; after 30 |
| 69 | 69 | days, or for a repository never moved, neither shows anything. |
| 70 | ||
| 71 | ## Renaming (#12154) | |
| 72 | ||
| 73 | The same machinery, under the same owner. Web: Settings › Rename. API: | |
| 74 | `PATCH /api/v1/repos/:owner/:name` with `{"name": "<new name>"}` (an admin-scope | |
| 75 | token; the owner or a site admin; refused with `name_taken`, `same_name` or | |
| 76 | `invalid_name`). MCP: `repo_settings` with `{ repo, name }`. Terminal: `:` then | |
| 77 | "Rename this repository". The repository keeps its id, so its app (and the | |
| 78 | app's address, which hangs off the app's slug, not the repository's name), | |
| 79 | pulls, issues, follows, forks and tokens keep working; the bare directory is | |
| 80 | renamed and each fork's borrowed-objects link rewritten. The old path is | |
| 81 | recorded in `repo_redirect`: a web or API read answers `301` (never cached: `no-store`) to the new path, | |
| 82 | a mutation `308`, git's fetch follows the redirect, and a push there is | |
| 83 | refused with a sentence naming the new address. It lasts until another | |
| 84 | repository takes the old name. A rename emits `repo.transferred`. |
src/actions/transfer.ts +34 −2
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | import type { App } from "../app.ts"; |
| 12 | 12 | import { getUserByName, type User } from "../core/users.ts"; |
| 13 | 13 | import { canAdmin, type Repo } from "../core/repos.ts"; |
| 14 | import { transferRepo, type TransferError } from "../core/transfer.ts"; | |
| 14 | import { renameRepo, transferRepo, type TransferError } from "../core/transfer.ts"; | |
| 15 | 15 | import { storageChanged } from "../core/storage.ts"; |
| 16 | 16 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 17 | 17 | import { resolveOwner } from "./repos.ts"; |
| @@ -19,8 +19,10 @@ import { resolveOwner } from "./repos.ts"; | ||
| 19 | 19 | export type TransferActionError = TransferError | "not_found" | "forbidden_owner" | "unknown_owner"; |
| 20 | 20 | |
| 21 | 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.", | |
| 22 | name_taken: "That owner already has a repository by that name.", | |
| 23 | 23 | same_owner: "The repository already belongs to that owner.", |
| 24 | same_name: "The repository already has that name.", | |
| 25 | invalid_name: "A name is letters, digits, dots, dashes and underscores, and starts with a letter or digit.", | |
| 24 | 26 | move_failed: "The repository could not be moved; nothing changed.", |
| 25 | 27 | forbidden_owner: "You cannot create repositories under that owner.", |
| 26 | 28 | unknown_owner: "No account or organisation has that name.", |
| @@ -30,6 +32,8 @@ export const TRANSFER_SAID: Readonly<Record<Exclude<TransferActionError, "not_fo | ||
| 30 | 32 | export const TRANSFER_STATUS: Readonly<Record<Exclude<TransferActionError, "not_found">, number>> = { |
| 31 | 33 | name_taken: 409, |
| 32 | 34 | same_owner: 409, |
| 35 | same_name: 409, | |
| 36 | invalid_name: 422, | |
| 33 | 37 | move_failed: 500, |
| 34 | 38 | forbidden_owner: 403, |
| 35 | 39 | unknown_owner: 422, |
| @@ -61,3 +65,31 @@ export async function transferRepository( | ||
| 61 | 65 | }); |
| 62 | 66 | return ok(moved.value); |
| 63 | 67 | } |
| 68 | ||
| 69 | /** | |
| 70 | * Rename a repository (#12154): an owner's or an admin's call, never an | |
| 71 | * agent's. Its id, git directory contents, app, pulls, issues, follows, forks | |
| 72 | * and tokens stay as they are; the old path answers with a redirect until | |
| 73 | * another repository takes the name. The event is `repo.transferred`: a | |
| 74 | * rename is a move to a new path, and its readers, feed and app say it so. | |
| 75 | */ | |
| 76 | export async function renameRepository( | |
| 77 | app: App, | |
| 78 | actor: User | undefined, | |
| 79 | repo: Repo, | |
| 80 | newName: string, | |
| 81 | ): Promise<Result<Repo, TransferActionError>> { | |
| 82 | if (actor === undefined || actor.kind === "agent" || !canAdmin(app.db, repo, actor)) return err("not_found"); | |
| 83 | const moved = await renameRepo(app.db, app.reposRoot, repo, newName, actor.id); | |
| 84 | if (!moved.ok) return moved; | |
| 85 | app.bus.append({ | |
| 86 | kind: "repo.transferred", | |
| 87 | repoId: repo.id, | |
| 88 | actorId: actor.id, | |
| 89 | payload: { | |
| 90 | from: `${repo.ownerName}/${repo.name}`, | |
| 91 | to: `${moved.value.ownerName}/${moved.value.name}`, | |
| 92 | }, | |
| 93 | }); | |
| 94 | return ok(moved.value); | |
| 95 | } |
src/contract/openapi/routes/patchApiV1ReposOwnerName.ts +4
| @@ -9,6 +9,7 @@ export const route: Route = { | ||
| 9 | 9 | auth: true, |
| 10 | 10 | role: "member", |
| 11 | 11 | body: { |
| 12 | name: "string", | |
| 12 | 13 | description: "string", |
| 13 | 14 | visibility: "string", |
| 14 | 15 | archived: "boolean", |
| @@ -22,6 +23,9 @@ export const route: Route = { | ||
| 22 | 23 | plansNeedNod: "boolean", |
| 23 | 24 | }, |
| 24 | 25 | refusals: { |
| 26 | invalid_name: "422: a name is letters, digits, dots, dashes and underscores", | |
| 27 | name_taken: "409: that owner already has a repository by that name", | |
| 28 | same_name: "409: the repository already has that name", | |
| 25 | 29 | bad_archived: "422: archived is true or false", |
| 26 | 30 | bad_decisions_by: "422: decisionsBy is me, decider or decider_tell", |
| 27 | 31 | bad_plans_need_nod: "422: plansNeedNod is true or false", |
src/core/transfer.ts +39 −9
| @@ -14,6 +14,7 @@ import type { Database } from "bun:sqlite"; | ||
| 14 | 14 | import { mkdir, readFile, rename, rmdir, writeFile } from "node:fs/promises"; |
| 15 | 15 | import { dirname, join } from "node:path"; |
| 16 | 16 | import { closeReader } from "../git/read.ts"; |
| 17 | import { isSafeName } from "../git/guard.ts"; | |
| 17 | 18 | import { repoDir, repoDirExists } from "../git/repos.ts"; |
| 18 | 19 | import { id } from "../kernel/id.ts"; |
| 19 | 20 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -21,7 +22,7 @@ import { forgetListings } from "./listings.ts"; | ||
| 21 | 22 | import { getRepo, getRepoById, listForks, type Repo } from "./repos.ts"; |
| 22 | 23 | import type { User } from "./users.ts"; |
| 23 | 24 | |
| 24 | export type TransferError = "name_taken" | "same_owner" | "move_failed"; | |
| 25 | export type TransferError = "name_taken" | "same_owner" | "same_name" | "invalid_name" | "move_failed"; | |
| 25 | 26 | |
| 26 | 27 | /** |
| 27 | 28 | * The old owner/name pairs, held in process memory per database so the |
| @@ -87,21 +88,50 @@ export function transferRepo( | ||
| 87 | 88 | return turn; |
| 88 | 89 | } |
| 89 | 90 | |
| 91 | /** | |
| 92 | * Renaming a repository (#12154): the same move, under the same owner, to a | |
| 93 | * new name. Its old path is left behind as a redirect exactly as a transfer | |
| 94 | * leaves one, so web, API and git at the old address reach the new one. | |
| 95 | */ | |
| 96 | export function renameRepo( | |
| 97 | db: Database, | |
| 98 | reposRoot: string, | |
| 99 | repo: Repo, | |
| 100 | newName: string, | |
| 101 | movedBy: string, | |
| 102 | ): Promise<Result<Repo, TransferError>> { | |
| 103 | const turn = queue.then(() => | |
| 104 | moveNow(db, reposRoot, repo, { id: repo.ownerId, username: repo.ownerName }, movedBy, newName), | |
| 105 | ); | |
| 106 | queue = turn.catch(() => undefined); | |
| 107 | return turn; | |
| 108 | } | |
| 109 | ||
| 90 | 110 | async function moveNow( |
| 91 | 111 | db: Database, |
| 92 | 112 | reposRoot: string, |
| 93 | 113 | stale: Repo, |
| 94 | target: User, | |
| 114 | target: Pick<User, "id" | "username">, | |
| 95 | 115 | movedBy: string, |
| 116 | newName?: string, | |
| 96 | 117 | ): Promise<Result<Repo, TransferError>> { |
| 97 | 118 | // The row as it is now, not as the caller read it before waiting its turn. |
| 98 | 119 | const repo = getRepoById(db, stale.id); |
| 99 | 120 | 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"); | |
| 121 | const name = newName ?? repo.name; | |
| 122 | if (newName === undefined && target.id === repo.ownerId) return err("same_owner"); | |
| 123 | if (newName !== undefined) { | |
| 124 | if (!isSafeName(newName)) return err("invalid_name"); | |
| 125 | if (newName === repo.name) return err("same_name"); | |
| 126 | } | |
| 127 | // A change of letter case only is the repository's own name, not a taken one. | |
| 128 | const holder = getRepo(db, target.username, name); | |
| 129 | if (holder !== undefined && holder.id !== repo.id) return err("name_taken"); | |
| 102 | 130 | 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"); | |
| 131 | const to = repoDir(reposRoot, target.username, name); | |
| 132 | if (name.toLowerCase() !== repo.name.toLowerCase() || target.id !== repo.ownerId) { | |
| 133 | if (await repoDirExists(to)) return err("name_taken"); | |
| 134 | } | |
| 105 | 135 | const hadDir = await repoDirExists(from); |
| 106 | 136 | if (hadDir) { |
| 107 | 137 | closeReader(from); |
| @@ -113,13 +143,13 @@ async function moveNow( | ||
| 113 | 143 | } |
| 114 | 144 | } |
| 115 | 145 | const fromPath = `${repo.ownerName}/${repo.name}`; |
| 116 | const toPath = `${target.username}/${repo.name}`; | |
| 146 | const toPath = `${target.username}/${name}`; | |
| 117 | 147 | try { |
| 118 | 148 | db.transaction(() => { |
| 119 | db.query("UPDATE repo SET owner_id = ?1 WHERE id = ?2").run(target.id, repo.id); | |
| 149 | db.query("UPDATE repo SET owner_id = ?1, name = ?2 WHERE id = ?3").run(target.id, name, repo.id); | |
| 120 | 150 | // The new path is a real repository now; a redirect that pointed it |
| 121 | 151 | // 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); | |
| 152 | db.query("DELETE FROM repo_redirect WHERE owner_name = ?1 AND name = ?2").run(target.username, name); | |
| 123 | 153 | db.query( |
| 124 | 154 | `INSERT INTO repo_redirect (owner_name, name, repo_id, moved_by) VALUES (?1, ?2, ?3, ?4) |
| 125 | 155 | ON CONFLICT (owner_name, name) DO UPDATE SET repo_id = excluded.repo_id, |
src/http/api.ts +19 −2
| @@ -126,7 +126,7 @@ import { | ||
| 126 | 126 | resolveOwner, |
| 127 | 127 | updateRepoSettings, |
| 128 | 128 | } from "../actions/repos.ts"; |
| 129 | import { TRANSFER_SAID, TRANSFER_STATUS, transferRepository } from "../actions/transfer.ts"; | |
| 129 | import { TRANSFER_SAID, TRANSFER_STATUS, renameRepository, transferRepository } from "../actions/transfer.ts"; | |
| 130 | 130 | import { |
| 131 | 131 | createToken, |
| 132 | 132 | HUMAN_CHECK_MESSAGE, |
| @@ -1696,7 +1696,24 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1696 | 1696 | if (getProvenance(ctx.app.db, repo.id) === undefined) |
| 1697 | 1697 | return apiError("no_provenance", 422, FAMILY_WORDS.no_provenance); |
| 1698 | 1698 | } |
| 1699 | const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, { | |
| 1699 | // A new name (#12154): an owner's or admin's, given alone, so a refused | |
| 1700 | // name or field can never leave the other half applied. | |
| 1701 | let current = repo; | |
| 1702 | if (input.name !== undefined) { | |
| 1703 | if (Object.keys(input).some((k) => k !== "name")) | |
| 1704 | return apiError("invalid", 422, "name renames the repository; give it alone"); | |
| 1705 | if (typeof input.name !== "string" || input.name.length === 0 || input.name.length > 100) | |
| 1706 | return apiError("invalid_name", 422, TRANSFER_SAID.invalid_name); | |
| 1707 | if (input.name !== repo.name) { | |
| 1708 | const renamed = await renameRepository(ctx.app, ctx.auth.user, repo, input.name); | |
| 1709 | if (!renamed.ok) { | |
| 1710 | if (renamed.error === "not_found") return notFound(); | |
| 1711 | return apiError(renamed.error, TRANSFER_STATUS[renamed.error], TRANSFER_SAID[renamed.error]); | |
| 1712 | } | |
| 1713 | current = renamed.value; | |
| 1714 | } | |
| 1715 | } | |
| 1716 | const updated = await updateRepoSettings(ctx.app, ctx.auth.user, current, { | |
| 1700 | 1717 | ...(input.visibility === "public" || input.visibility === "private" |
| 1701 | 1718 | ? { visibility: input.visibility } |
| 1702 | 1719 | : {}), |
src/http/mcp/tools/repo_settings.ts +21 −4
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { updateRepoSettings } from "../../../actions/repos.ts"; |
| 2 | import { TRANSFER_SAID, transferRepository } from "../../../actions/transfer.ts"; | |
| 2 | import { TRANSFER_SAID, renameRepository, transferRepository } from "../../../actions/transfer.ts"; | |
| 3 | 3 | import { mirrorPacks, reportWords } from "../../../packs/mirror.ts"; |
| 4 | 4 | import { rejectedMarkedSentence } from "../../../packs/marked.ts"; |
| 5 | 5 | import { setPackRepos } from "../../../packs/set.ts"; |
| @@ -8,9 +8,9 @@ import { VISIBILITY_WORDS } from "../../../core/arg_words.ts"; | ||
| 8 | 8 | |
| 9 | 9 | export const tool: Tool = { |
| 10 | 10 | name: "repo_settings", |
| 11 | doc: "`{ packRepos: [\"packs/games\", …] }` (admin) sets the repositories /packs lists, as Settings › Packs does (public repos with a pack.json at the root; at most 20; an empty list lists none), and answers `{ repos, problems }` with the ones left out and why; it shows on /packs at once; a word that is not `owner/name` is `invalid`. `{ mirrorPacks: true }` (admin) brings each https source in a marked pack repo's pack.json in once as a read-only `packs/<app>` that follows its upstream, and answers `{ made, kept, failed, says }`; running it again makes nothing new. `{ repo, description?, visibility?, newOwner? }` (admin): `newOwner` moves the repository to that account or org as `POST /api/v1/repos/:owner/:name/transfer` does, with its codes and sentences (`name_taken`, `same_owner`, `move_failed`, `forbidden_owner`, `unknown_owner`) and answers `{ repo, description, visibility }` at the new path, and cannot be combined with the others; otherwise change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }` (with none to change it only reads); a visibility other than public or private is `invalid`", | |
| 11 | doc: "`{ packRepos: [\"packs/games\", …] }` (admin) sets the repositories /packs lists, as Settings › Packs does (public repos with a pack.json at the root; at most 20; an empty list lists none), and answers `{ repos, problems }` with the ones left out and why; it shows on /packs at once; a word that is not `owner/name` is `invalid`. `{ mirrorPacks: true }` (admin) brings each https source in a marked pack repo's pack.json in once as a read-only `packs/<app>` that follows its upstream, and answers `{ made, kept, failed, says }`; running it again makes nothing new. `{ repo, name }` (admin) renames the repository as `PATCH /api/v1/repos/:owner/:name` with `name` does: its pulls, issues, forks, app and tokens stay, its old web and git address redirect to the new one (a push there is refused naming it), with the codes `name_taken`, `same_name`, `invalid_name`, `move_failed`; give it alone; answers `{ repo, description, visibility }` at the new path. `{ repo, description?, visibility?, newOwner? }` (admin): `newOwner` moves the repository to that account or org as `POST /api/v1/repos/:owner/:name/transfer` does, with its codes and sentences (`name_taken`, `same_owner`, `move_failed`, `forbidden_owner`, `unknown_owner`) and answers `{ repo, description, visibility }` at the new path, and cannot be combined with the others; otherwise change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }` (with none to change it only reads); a visibility other than public or private is `invalid`", | |
| 12 | 12 | description: |
| 13 | "Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams.", | |
| 13 | "Read, change, rename (name) or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams.", | |
| 14 | 14 | inputSchema: { |
| 15 | 15 | type: "object", |
| 16 | 16 | properties: { |
| @@ -18,6 +18,7 @@ export const tool: Tool = { | ||
| 18 | 18 | description: { type: "string", maxLength: 2000 }, |
| 19 | 19 | visibility: { type: "string", enum: ["public", "private"] }, |
| 20 | 20 | newOwner: { type: "string", maxLength: 64 }, |
| 21 | name: { type: "string", maxLength: 100 }, | |
| 21 | 22 | mirrorPacks: { type: "boolean" }, |
| 22 | 23 | packRepos: { type: "array", items: { type: "string", maxLength: 100 }, maxItems: 20 }, |
| 23 | 24 | }, |
| @@ -39,7 +40,23 @@ export const tool: Tool = { | ||
| 39 | 40 | } |
| 40 | 41 | const repo = repoArg(ctx, user, args); |
| 41 | 42 | if (isAnswer(repo)) return repo; |
| 42 | const { description, visibility, newOwner } = args; | |
| 43 | const { description, visibility, newOwner, name } = args; | |
| 44 | if (name !== undefined) { | |
| 45 | if (typeof name !== "string" || name.length === 0 || name.length > 100) | |
| 46 | return bad("invalid_name", TRANSFER_SAID.invalid_name); | |
| 47 | if (description !== undefined || visibility !== undefined || newOwner !== undefined) | |
| 48 | return bad("invalid", "name renames the repository; give it alone"); | |
| 49 | const renamed = await renameRepository(ctx.app, user, repo, name); | |
| 50 | if (!renamed.ok) | |
| 51 | return renamed.error === "not_found" | |
| 52 | ? bad("not_found", "no such app or repository (owner/name)") | |
| 53 | : bad(renamed.error, TRANSFER_SAID[renamed.error]); | |
| 54 | return good({ | |
| 55 | repo: `${renamed.value.ownerName}/${renamed.value.name}`, | |
| 56 | description: renamed.value.description, | |
| 57 | visibility: renamed.value.visibility, | |
| 58 | }); | |
| 59 | } | |
| 43 | 60 | if (newOwner !== undefined) { |
| 44 | 61 | if (typeof newOwner !== "string" || newOwner.length === 0 || newOwner.length > 64) |
| 45 | 62 | return bad("bad_new_owner", "newOwner is an account or organisation name"); |
src/http/moved.ts +11 −6
| @@ -1,8 +1,9 @@ | ||
| 1 | 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. | |
| 2 | * A moved or renamed repository's old paths (#569, #12154). A web or API read | |
| 3 | * under an owner/name that a move left behind answers 301 (308 for a mutation) | |
| 4 | * to the same path at the repository's new address, never cached: a mutation | |
| 5 | * is never applied silently to the new place, the client re-sends it, knowing | |
| 6 | * where. | |
| 6 | 7 | * Only a caller who may read the repository learns where it went; anyone else |
| 7 | 8 | * keeps the 404 a missing path has always answered. |
| 8 | 9 | */ |
| @@ -38,7 +39,7 @@ export function movedTarget(db: Database, url: URL): Repo | undefined { | ||
| 38 | 39 | return at === undefined ? undefined : redirectFor(db, at.owner, at.name); |
| 39 | 40 | } |
| 40 | 41 | |
| 41 | export function movedResponse(db: Database, url: URL, user: User | undefined): Response | undefined { | |
| 42 | export function movedResponse(db: Database, url: URL, user: User | undefined, method = "GET"): Response | undefined { | |
| 42 | 43 | const at = repoPath(url.pathname); |
| 43 | 44 | if (at === undefined) return undefined; |
| 44 | 45 | const repo = redirectFor(db, at.owner, at.name); |
| @@ -54,5 +55,9 @@ export function movedResponse(db: Database, url: URL, user: User | undefined): R | ||
| 54 | 55 | } |
| 55 | 56 | const rest = at.rest === "" && !url.pathname.endsWith("/") ? "" : `/${at.rest}`; |
| 56 | 57 | const location = `${at.prefix}${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}${suffix}${rest}${url.search}`; |
| 57 | return new Response(null, { status: 308, headers: { location } }); | |
| 58 | // A read is moved for good (301; git follows it); a mutation is 308, so it | |
| 59 | // is re-sent as it was, never turned into a GET. | |
| 60 | const read = method === "GET" || method === "HEAD"; | |
| 61 | // Never cached: the redirect ends when another repository takes the old name. | |
| 62 | return new Response(null, { status: read ? 301 : 308, headers: { location, "cache-control": "no-store" } }); | |
| 58 | 63 | } |
src/http/server.ts +3 −1
| @@ -431,6 +431,7 @@ import { | ||
| 431 | 431 | archiveSubmit, |
| 432 | 432 | archiveConfirmed, |
| 433 | 433 | transferSubmit, |
| 434 | renameSubmit, | |
| 434 | 435 | provenanceSubmit, |
| 435 | 436 | mirrorStop, |
| 436 | 437 | } from "../web/pages/settings.ts"; |
| @@ -1603,6 +1604,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1603 | 1604 | router.add("POST", "/:owner/:name/settings/wave", waveCut); |
| 1604 | 1605 | router.add("POST", "/:owner/:name/settings/archive", archiveSubmit); |
| 1605 | 1606 | router.add("POST", "/:owner/:name/archive", archiveConfirmed); |
| 1607 | router.add("POST", "/:owner/:name/settings/rename", renameSubmit); | |
| 1606 | 1608 | router.add("POST", "/:owner/:name/settings/transfer", transferSubmit); |
| 1607 | 1609 | router.add("POST", "/:owner/:name/settings/provenance", provenanceSubmit); |
| 1608 | 1610 | router.add("POST", "/:owner/:name/settings/mirror/stop", mirrorStop); |
| @@ -3120,7 +3122,7 @@ async function answerGet( | ||
| 3120 | 3122 | if (adminRefusal !== undefined) return adminRefusal; |
| 3121 | 3123 | |
| 3122 | 3124 | // A transferred repository's old path answers 308 to its new one (#569). |
| 3123 | const moved = movedResponse(app.db, url, auth.user); | |
| 3125 | const moved = movedResponse(app.db, url, auth.user, request.method); | |
| 3124 | 3126 | if (moved !== undefined) return moved; |
| 3125 | 3127 | |
| 3126 | 3128 | // The two things standing between one runaway agent and everybody |
src/tui/screens/transferrepo.ts +28 −1
| @@ -5,13 +5,14 @@ | ||
| 5 | 5 | * Only a person who administers the repository is offered it. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { TRANSFER_SAID, transferRepository } from "../../actions/transfer.ts"; | |
| 8 | import { TRANSFER_SAID, renameRepository, transferRepository } from "../../actions/transfer.ts"; | |
| 9 | 9 | import { canAdmin, type Repo } from "../../core/repos.ts"; |
| 10 | 10 | import type { Ctx, Screen } from "../app/app.ts"; |
| 11 | 11 | import type { Command } from "../app/palette.ts"; |
| 12 | 12 | import type { Session } from "../session.ts"; |
| 13 | 13 | import { promptScreen } from "./common.ts"; |
| 14 | 14 | |
| 15 | export const RENAME_REPO = "Rename this repository"; | |
| 15 | 16 | export const TRANSFER_REPO = "Move this repository to another owner"; |
| 16 | 17 | |
| 17 | 18 | /** The prompt floats over whatever is already drawn; this is the empty backdrop it asks for. */ |
| @@ -27,6 +28,32 @@ export function transferRepoCommands(f: Session, ctx: Ctx, repo: Repo | undefine | ||
| 27 | 28 | if (!canAdmin(f.db, repo, f.user)) return []; |
| 28 | 29 | const full = `${repo.ownerName}/${repo.name}`; |
| 29 | 30 | return [ |
| 31 | { | |
| 32 | id: "rename-repository", | |
| 33 | title: `${full} — ${RENAME_REPO}`, | |
| 34 | hint: "the old address redirects to the new one", | |
| 35 | run: () => | |
| 36 | ctx.push( | |
| 37 | promptScreen(ctx, backdrop, { | |
| 38 | title: RENAME_REPO, | |
| 39 | label: `Renames ${full}; its old address keeps redirecting. New name:`, | |
| 40 | placeholder: repo.name, | |
| 41 | onSubmit: (typed) => { | |
| 42 | const name = typed.trim(); | |
| 43 | if (name === "") return "Type the new name."; | |
| 44 | return (async () => { | |
| 45 | const renamed = await renameRepository(f.app, f.user, repo, name); | |
| 46 | if (!renamed.ok) { | |
| 47 | ctx.toast(renamed.error === "not_found" ? "no such repository" : TRANSFER_SAID[renamed.error], "danger"); | |
| 48 | return; | |
| 49 | } | |
| 50 | ctx.toast(`renamed ${full} to ${renamed.value.ownerName}/${renamed.value.name}`, "success"); | |
| 51 | ctx.leaveRepo(repo.id); | |
| 52 | })(); | |
| 53 | }, | |
| 54 | }), | |
| 55 | ), | |
| 56 | }, | |
| 30 | 57 | { |
| 31 | 58 | id: "transfer-repository", |
| 32 | 59 | title: `${full} — ${TRANSFER_REPO}`, |
src/web/pages/settings.ts +40 −5
| @@ -53,7 +53,7 @@ import { | ||
| 53 | 53 | SCOPE_WORDS, |
| 54 | 54 | } from "../../actions/app_env_words.ts"; |
| 55 | 55 | import { AUTONOMY_SAID, checkAutonomy, isDecisionsBy } from "../../core/repos.ts"; |
| 56 | import { TRANSFER_SAID, TRANSFER_STATUS, transferRepository } from "../../actions/transfer.ts"; | |
| 56 | import { TRANSFER_SAID, TRANSFER_STATUS, renameRepository, transferRepository } from "../../actions/transfer.ts"; | |
| 57 | 57 | import { updateRepoSettings } from "../../actions/repos.ts"; |
| 58 | 58 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 59 | 59 | import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| @@ -238,7 +238,7 @@ export async function adminForm( | ||
| 238 | 238 | export async function settingsPage( |
| 239 | 239 | ctx: Ctx, |
| 240 | 240 | /** A refused move: the owner as typed and the sentence to show beside it. */ |
| 241 | refused?: { owner: string; said: string; status: number }, | |
| 241 | refused?: { owner: string; name?: string; said: string; status: number }, | |
| 242 | 242 | ): Promise<Response> { |
| 243 | 243 | nameFrame(ctx.request); |
| 244 | 244 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -350,21 +350,36 @@ export async function settingsPage( | ||
| 350 | 350 | </div> |
| 351 | 351 | </form> |
| 352 | 352 | </section>`; |
| 353 | const rename = html`<form id="rename" method="post" action="${base}/settings/rename"> | |
| 354 | ${formField({ | |
| 355 | label: "New name", | |
| 356 | labelHidden: true, | |
| 357 | name: "name", | |
| 358 | value: refused?.name ?? repo.name, | |
| 359 | required: true, | |
| 360 | maxlength: 100, | |
| 361 | autocomplete: "off", | |
| 362 | spellcheck: false, | |
| 363 | mono: true, | |
| 364 | ...(refused?.name !== undefined ? { error: refused.said } : {}), | |
| 365 | })} | |
| 366 | ${btn("Rename", { variant: "primary" })} | |
| 367 | </form>`; | |
| 353 | 368 | const move = html`<section class="settings-block" id="move"> |
| 354 | 369 | <h2>Move to another owner</h2> |
| 355 | <p class="muted small">Hands the repository to someone else.</p> | |
| 370 | ${rename} | |
| 356 | 371 | <form method="get" action="/-/confirm"> |
| 357 | 372 | <input type="hidden" name="to" value="${base}/settings/transfer" /> |
| 358 | 373 | ${formField({ |
| 359 | 374 | label: "New owner", |
| 360 | 375 | name: "owner", |
| 361 | value: refused?.owner ?? "", | |
| 376 | value: refused?.name === undefined ? (refused?.owner ?? "") : "", | |
| 362 | 377 | required: true, |
| 363 | 378 | maxlength: 64, |
| 364 | 379 | autocomplete: "off", |
| 365 | 380 | spellcheck: false, |
| 366 | 381 | mono: true, |
| 367 | ...(refused ? { error: refused.said } : {}), | |
| 382 | ...(refused !== undefined && refused.name === undefined ? { error: refused.said } : {}), | |
| 368 | 383 | })} |
| 369 | 384 | <div class="form-actions">${btn("Move", { variant: "danger" })}</div> |
| 370 | 385 | </form> |
| @@ -549,6 +564,26 @@ export async function transferSubmit(ctx: Ctx): Promise<Response> { | ||
| 549 | 564 | }); |
| 550 | 565 | } |
| 551 | 566 | |
| 567 | /** | |
| 568 | * POST /:owner/:name/settings/rename (#12154) — give the repository a new | |
| 569 | * name, through the action. A refusal is the settings page again with the | |
| 570 | * sentence beside the field; success lands on the new address. | |
| 571 | */ | |
| 572 | export async function renameSubmit(ctx: Ctx): Promise<Response> { | |
| 573 | const gate = await adminForm(ctx); | |
| 574 | if (gate instanceof Response) return gate; | |
| 575 | const { repo, form } = gate; | |
| 576 | const name = (field(form, "name") ?? "").trim(); | |
| 577 | const renamed = await renameRepository(ctx.app, ctx.auth.user, repo, name.slice(0, 100)); | |
| 578 | if (!renamed.ok) { | |
| 579 | if (renamed.error === "not_found") return repoNotFound(ctx); | |
| 580 | return settingsPage(ctx, { owner: "", name: name.slice(0, 100), said: TRANSFER_SAID[renamed.error], status: TRANSFER_STATUS[renamed.error] }); | |
| 581 | } | |
| 582 | return flashTo(ctx, `${repoBase(renamed.value)}/settings`, { | |
| 583 | message: `Renamed to ${renamed.value.ownerName}/${renamed.value.name}.`, | |
| 584 | }); | |
| 585 | } | |
| 586 | ||
| 552 | 587 | /** |
| 553 | 588 | * "Show where this came from" (#3293), drawn only for a repository that |
| 554 | 589 | * has a trail to show: the line as it reads, and the one switch for it. |
test/moved-git.test.ts +2 −2
| @@ -72,9 +72,9 @@ describe("git through a moved repository's old URL", () => { | ||
| 72 | 72 | await rm(dataDir, { recursive: true, force: true }); |
| 73 | 73 | }); |
| 74 | 74 | |
| 75 | test("the advertisement answers 308 to the same path under the new owner", async () => { | |
| 75 | test("the advertisement answers 301 to the same path under the new owner", async () => { | |
| 76 | 76 | const res = await fetch(`${servers.url}/alice/demo.git/info/refs?service=git-upload-pack`, { redirect: "manual" }); |
| 77 | expect(res.status).toBe(308); | |
| 77 | expect(res.status).toBe(301); | |
| 78 | 78 | expect(res.headers.get("location")).toBe("/bob/demo.git/info/refs?service=git-upload-pack"); |
| 79 | 79 | }); |
| 80 | 80 |
test/repo-rename-doors.test.ts added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | /** | |
| 2 | * The doors to a rename (#12154) beside the API's: the Settings form, the MCP | |
| 3 | * tool and the redirect's caching. Each refusal changes nothing. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { TRANSFER_SAID } from "../src/actions/transfer.ts"; | |
| 12 | import { getRepo } from "../src/core/repos.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | const PASSWORD = "a-long-rename-doors-passphrase"; | |
| 21 | ||
| 22 | describe("renaming through the web form and MCP", () => { | |
| 23 | let dataDir = ""; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | const cookies: Record<string, string> = {}; | |
| 27 | const tokens: Record<string, string> = {}; | |
| 28 | let id = 1; | |
| 29 | ||
| 30 | const post = (path: string, who: string, fields: Record<string, string>, origin: string | false = servers.url) => | |
| 31 | fetch(`${servers.url}${path}`, { | |
| 32 | method: "POST", | |
| 33 | redirect: "manual", | |
| 34 | headers: { cookie: cookies[who]!, ...(origin ? { origin } : {}) }, | |
| 35 | body: new URLSearchParams(fields), | |
| 36 | }); | |
| 37 | const mcp = async (who: string, args: unknown) => { | |
| 38 | const res = await fetch(`${servers.url}/mcp`, { | |
| 39 | method: "POST", | |
| 40 | headers: { | |
| 41 | "content-type": "application/json", | |
| 42 | accept: "application/json, text/event-stream", | |
| 43 | authorization: `Bearer ${tokens[who]}`, | |
| 44 | }, | |
| 45 | body: JSON.stringify({ | |
| 46 | jsonrpc: "2.0", | |
| 47 | id: id++, | |
| 48 | method: "tools/call", | |
| 49 | params: { name: "repo_settings", arguments: args }, | |
| 50 | }), | |
| 51 | }); | |
| 52 | const body = (await res.json()) as any; | |
| 53 | return { text: body.result.content[0].text as string, isError: body.result.isError === true }; | |
| 54 | }; | |
| 55 | ||
| 56 | beforeAll(async () => { | |
| 57 | dataDir = await mkdtemp(join(tmpdir(), "forge-rename-doors-")); | |
| 58 | app = createApp({ dataDir, port: 0 }); | |
| 59 | servers = await serve(app); | |
| 60 | for (const username of ["alice", "bob"]) { | |
| 61 | const u = await createUser(app.db, { | |
| 62 | origin: "seed", | |
| 63 | trusted: true, | |
| 64 | username, | |
| 65 | email: `${username}@forge.local`, | |
| 66 | password: PASSWORD, | |
| 67 | adminIfFirst: username === "alice", | |
| 68 | }); | |
| 69 | if (!u.ok) throw new Error(u.error); | |
| 70 | tokens[username] = createToken(app.db, u.value.id, "admin", "admin").token; | |
| 71 | const res = await fetch(`${servers.url}/signin`, { | |
| 72 | method: "POST", | |
| 73 | body: new URLSearchParams({ username, password: PASSWORD }), | |
| 74 | redirect: "manual", | |
| 75 | }); | |
| 76 | cookies[username] = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 77 | if (username === "alice") { | |
| 78 | for (const name of ["web", "webtaken", "tool", "toolb", "patchy"]) { | |
| 79 | const r = await createRepo(app, u.value, { name, visibility: "public" }); | |
| 80 | if (!r.ok) throw new Error(String(r.error)); | |
| 81 | } | |
| 82 | } | |
| 83 | } | |
| 84 | }); | |
| 85 | ||
| 86 | afterAll(async () => { | |
| 87 | await servers.stop(); | |
| 88 | app.db.close(); | |
| 89 | await rm(dataDir, { recursive: true, force: true }); | |
| 90 | }); | |
| 91 | ||
| 92 | test("web: a stranger gets 404 and a cross-origin post is refused; nothing is renamed", async () => { | |
| 93 | expect((await post("/alice/web/settings/rename", "bob", { name: "x1" })).status).toBe(404); | |
| 94 | expect((await post("/alice/web/settings/rename", "alice", { name: "x1" }, "https://evil.example")).status).toBe(403); | |
| 95 | expect(getRepo(app.db, "alice", "web")).toBeDefined(); | |
| 96 | expect(getRepo(app.db, "alice", "x1")).toBeUndefined(); | |
| 97 | }); | |
| 98 | ||
| 99 | test("web: a refusal shows its sentence beside the field, with the typed name kept", async () => { | |
| 100 | const taken = await post("/alice/web/settings/rename", "alice", { name: "webtaken" }); | |
| 101 | expect(taken.status).toBe(409); | |
| 102 | const text = await taken.text(); | |
| 103 | expect(text).toContain(TRANSFER_SAID.name_taken); | |
| 104 | expect(text).toContain('value="webtaken"'); | |
| 105 | const bad = await post("/alice/web/settings/rename", "alice", { name: "no/slash" }); | |
| 106 | expect(bad.status).toBe(422); | |
| 107 | expect(await bad.text()).toContain("starts with a letter or digit"); | |
| 108 | expect(getRepo(app.db, "alice", "web")).toBeDefined(); | |
| 109 | }); | |
| 110 | ||
| 111 | test("web: a rename lands on Settings at the new address with a flash", async () => { | |
| 112 | const res = await post("/alice/web/settings/rename", "alice", { name: "webbed" }); | |
| 113 | expect(res.status).toBe(303); | |
| 114 | expect(res.headers.get("location")).toContain("/alice/webbed/settings"); | |
| 115 | expect(getRepo(app.db, "alice", "webbed")).toBeDefined(); | |
| 116 | // The flash rides a cookie the redirect sets; the browser sends it back. | |
| 117 | const flash = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 118 | const page = await fetch(`${servers.url}/alice/webbed/settings`, { | |
| 119 | headers: { cookie: `${cookies.alice!}; ${flash}` }, | |
| 120 | }); | |
| 121 | expect(await page.text()).toContain("Renamed to alice/webbed."); | |
| 122 | }); | |
| 123 | ||
| 124 | test("the old address redirects and is never cached", async () => { | |
| 125 | const res = await fetch(`${servers.url}/alice/web`, { redirect: "manual", headers: { cookie: cookies.alice! } }); | |
| 126 | expect(res.status).toBe(301); | |
| 127 | expect(res.headers.get("location")).toBe("/alice/webbed"); | |
| 128 | expect(res.headers.get("cache-control")).toBe("no-store"); | |
| 129 | }); | |
| 130 | ||
| 131 | test("mcp: an admin renames; the old name no longer finds the repository", async () => { | |
| 132 | const res = await mcp("alice", { repo: "alice/tool", name: "tooled" }); | |
| 133 | expect(res.isError).toBe(false); | |
| 134 | expect(JSON.parse(res.text)).toMatchObject({ repo: "alice/tooled" }); | |
| 135 | expect(getRepo(app.db, "alice", "tooled")).toBeDefined(); | |
| 136 | expect(getRepo(app.db, "alice", "tool")).toBeUndefined(); | |
| 137 | }); | |
| 138 | ||
| 139 | test("mcp: name with another field is invalid and renames nothing", async () => { | |
| 140 | const res = await mcp("alice", { repo: "alice/toolb", name: "toolc", description: "x" }); | |
| 141 | expect(res.isError).toBe(true); | |
| 142 | expect(res.text).toContain("invalid"); | |
| 143 | expect(getRepo(app.db, "alice", "toolb")).toBeDefined(); | |
| 144 | expect(getRepo(app.db, "alice", "toolc")).toBeUndefined(); | |
| 145 | }); | |
| 146 | ||
| 147 | test("mcp: a non-admin is refused", async () => { | |
| 148 | const res = await mcp("bob", { repo: "alice/toolb", name: "toolc" }); | |
| 149 | expect(res.isError).toBe(true); | |
| 150 | expect(getRepo(app.db, "alice", "toolb")).toBeDefined(); | |
| 151 | }); | |
| 152 | ||
| 153 | test("api: name with another field is invalid and neither is applied", async () => { | |
| 154 | const res = await fetch(`${servers.url}/api/v1/repos/alice/patchy`, { | |
| 155 | method: "PATCH", | |
| 156 | headers: { authorization: `Bearer ${tokens.alice}`, "content-type": "application/json" }, | |
| 157 | body: JSON.stringify({ name: "patched", description: "changed" }), | |
| 158 | }); | |
| 159 | expect(res.status).toBe(422); | |
| 160 | expect(getRepo(app.db, "alice", "patchy")?.description).not.toBe("changed"); | |
| 161 | expect(getRepo(app.db, "alice", "patched")).toBeUndefined(); | |
| 162 | }); | |
| 163 | }); |
test/repo-rename.test.ts added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | /** | |
| 2 | * Renaming a repository (#12154): everything keyed by its id keeps working; | |
| 3 | * the old address redirects. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { openPull } from "../src/actions/pulls.ts"; | |
| 11 | import { openIssue } from "../src/actions/issues.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { renameRepository } from "../src/actions/transfer.ts"; | |
| 14 | import { createApp as createAppRow, getAppByRepo } from "../src/core/apps.ts"; | |
| 15 | import { forkRepo, getRepo, getRepoById, listForks, type Repo } from "../src/core/repos.ts"; | |
| 16 | import { transfersOf } from "../src/core/transfer.ts"; | |
| 17 | import { createToken } from "../src/core/tokens.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { runGit } from "../src/git/env.ts"; | |
| 20 | import { repoDir } from "../src/git/repos.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | describe("renaming a repository", () => { | |
| 27 | let dataDir = ""; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | let alice: User; | |
| 31 | let bob: User; | |
| 32 | let repo: Repo; | |
| 33 | let fork: Repo; | |
| 34 | let aliceRw = ""; | |
| 35 | let bobRw = ""; | |
| 36 | let pullNumber = 0; | |
| 37 | let issueNumber = 0; | |
| 38 | ||
| 39 | const call = (path: string, token: string, method = "GET", payload?: unknown) => | |
| 40 | fetch(`${servers.url}${path}`, { | |
| 41 | method, | |
| 42 | redirect: "manual", | |
| 43 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 44 | ...(payload === undefined ? {} : { body: JSON.stringify(payload) }), | |
| 45 | }); | |
| 46 | ||
| 47 | beforeAll(async () => { | |
| 48 | dataDir = await mkdtemp(join(tmpdir(), "forge-rename-")); | |
| 49 | app = createApp({ dataDir, port: 0 }); | |
| 50 | servers = await serve(app); | |
| 51 | const made = async (username: string, admin: boolean) => { | |
| 52 | const u = await createUser(app.db, { | |
| 53 | origin: "seed", | |
| 54 | trusted: true, | |
| 55 | username, | |
| 56 | email: `${username}@forge.local`, | |
| 57 | password: "a-long-rename-passphrase", | |
| 58 | adminIfFirst: admin, | |
| 59 | }); | |
| 60 | if (!u.ok) throw new Error(u.error); | |
| 61 | return u.value; | |
| 62 | }; | |
| 63 | alice = await made("alice", true); | |
| 64 | bob = await made("bob", false); | |
| 65 | aliceRw = createToken(app.db, alice.id, "admin", "admin").token; | |
| 66 | bobRw = createToken(app.db, bob.id, "admin", "admin").token; | |
| 67 | const r = await createRepo(app, alice, { name: "mitosis", visibility: "public" }); | |
| 68 | if (!r.ok) throw new Error(String(r.error)); | |
| 69 | repo = r.value; | |
| 70 | await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# m\n" }] }); | |
| 71 | await commitToBranch(app, alice, repo, { | |
| 72 | branch: "topic", | |
| 73 | base: "main", | |
| 74 | message: "topic", | |
| 75 | files: [{ path: "T.md", content: "t\n" }], | |
| 76 | }); | |
| 77 | const pull = await openPull(app, alice, repo, { title: "Topic", body: "", baseBranch: "main", headBranch: "topic" }); | |
| 78 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 79 | pullNumber = pull.value.number; | |
| 80 | const issue = openIssue(app, alice, repo, { title: "Bookmarked" }); | |
| 81 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 82 | issueNumber = issue.value.number; | |
| 83 | const row = createAppRow(app.db, { repoId: repo.id, slug: "mitosis", access: "public" }); | |
| 84 | if (!row.ok) throw new Error(row.error); | |
| 85 | const forked = await forkRepo(app.db, app.reposRoot, repo, bob.id); | |
| 86 | if (!forked.ok) throw new Error(String(forked.error)); | |
| 87 | fork = forked.value; | |
| 88 | }, 30_000); | |
| 89 | ||
| 90 | afterAll(async () => { | |
| 91 | servers.stop(); | |
| 92 | app.close(); | |
| 93 | await rm(dataDir, { recursive: true, force: true }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("refusals: a stranger, a bad name, the same name, a taken name — nothing changes", async () => { | |
| 97 | const other = await createRepo(app, alice, { name: "taken", visibility: "public" }); | |
| 98 | if (!other.ok) throw new Error(String(other.error)); | |
| 99 | expect((await call("/api/v1/repos/alice/mitosis", bobRw, "PATCH", { name: "x" })).status).toBe(404); | |
| 100 | for (const name of ["../x", "a b", "", 7, ".git"]) { | |
| 101 | expect((await call("/api/v1/repos/alice/mitosis", aliceRw, "PATCH", { name })).status).toBe(422); | |
| 102 | } | |
| 103 | expect((await call("/api/v1/repos/alice/mitosis", aliceRw, "PATCH", { name: "taken" })).status).toBe(409); | |
| 104 | expect(getRepo(app.db, "alice", "mitosis")?.id).toBe(repo.id); | |
| 105 | expect((await renameRepository(app, bob, repo, "nope")).ok).toBe(false); | |
| 106 | }); | |
| 107 | ||
| 108 | test("an owner renames it by the API; pull, issue, fork, app and git all still resolve", async () => { | |
| 109 | const res = await call("/api/v1/repos/alice/mitosis", aliceRw, "PATCH", { name: "cell" }); | |
| 110 | expect(res.status).toBe(200); | |
| 111 | expect(getRepoById(app.db, repo.id)?.name).toBe("cell"); | |
| 112 | expect(transfersOf(app.db, repo.id).map((t) => t.to)).toEqual(["alice/cell"]); | |
| 113 | ||
| 114 | expect((await call(`/api/v1/repos/alice/cell/pulls/${pullNumber}`, aliceRw)).status).toBe(200); | |
| 115 | expect((await call(`/api/v1/repos/alice/cell/issues/${issueNumber}`, aliceRw)).status).toBe(200); | |
| 116 | expect(getAppByRepo(app.db, repo.id)?.slug).toBe("mitosis"); | |
| 117 | expect(listForks(app.db, repo.id).map((f) => f.id)).toEqual([fork.id]); | |
| 118 | ||
| 119 | // The bare repository moved, and the fork's borrowed objects follow it. | |
| 120 | const log = await runGit(["log", "--format=%s", "main"], { cwd: repoDir(app.reposRoot, "alice", "cell") }); | |
| 121 | expect(log.ok).toBe(true); | |
| 122 | const inFork = await runGit(["log", "--format=%s", "main"], { cwd: repoDir(app.reposRoot, "bob", "mitosis") }); | |
| 123 | expect(inFork.ok && inFork.value.trim()).toBe("seed"); | |
| 124 | }); | |
| 125 | ||
| 126 | test("the old addresses redirect for good; a mutation is re-sent; a push is refused naming the new path", async () => { | |
| 127 | const page = await call("/alice/mitosis", aliceRw); | |
| 128 | expect(page.status).toBe(301); | |
| 129 | expect(page.headers.get("location")).toBe("/alice/cell"); | |
| 130 | const api = await call(`/api/v1/repos/alice/mitosis/pulls/${pullNumber}?x=1`, aliceRw); | |
| 131 | expect(api.status).toBe(301); | |
| 132 | expect(api.headers.get("location")).toBe(`/api/v1/repos/alice/cell/pulls/${pullNumber}?x=1`); | |
| 133 | const git = await fetch(`${servers.url}/alice/mitosis.git/info/refs?service=git-upload-pack`, { redirect: "manual" }); | |
| 134 | expect(git.status).toBe(301); | |
| 135 | expect(git.headers.get("location")).toBe("/alice/cell.git/info/refs?service=git-upload-pack"); | |
| 136 | const post = await call("/api/v1/repos/alice/mitosis/issues", aliceRw, "POST", { title: "x" }); | |
| 137 | expect(post.status).toBe(308); | |
| 138 | const push = await fetch(`${servers.url}/alice/mitosis.git/info/refs?service=git-receive-pack`, { redirect: "manual" }); | |
| 139 | expect(push.status).toBe(403); | |
| 140 | expect(await push.text()).toContain("moved to alice/cell"); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a second rename keeps the first old address; another repository taking the name ends it", async () => { | |
| 144 | expect((await call("/api/v1/repos/alice/cell", aliceRw, "PATCH", { name: "organelle" })).status).toBe(200); | |
| 145 | for (const old of ["mitosis", "cell"]) { | |
| 146 | const res = await call(`/alice/${old}`, aliceRw); | |
| 147 | expect(res.status).toBe(301); | |
| 148 | expect(res.headers.get("location")).toBe("/alice/organelle"); | |
| 149 | } | |
| 150 | const taker = await createRepo(app, alice, { name: "mitosis", visibility: "public" }); | |
| 151 | if (!taker.ok) throw new Error(String(taker.error)); | |
| 152 | expect((await call("/alice/mitosis", aliceRw)).status).toBe(200); | |
| 153 | expect(getRepo(app.db, "alice", "organelle")?.id).toBe(repo.id); | |
| 154 | }); | |
| 155 | ||
| 156 | test("a case-only rename is allowed", async () => { | |
| 157 | const res = await call("/api/v1/repos/alice/organelle", aliceRw, "PATCH", { name: "Organelle" }); | |
| 158 | expect(res.status).toBe(200); | |
| 159 | expect(getRepoById(app.db, repo.id)?.name).toBe("Organelle"); | |
| 160 | const log = await runGit(["log", "--format=%s", "main"], { cwd: repoDir(app.reposRoot, "alice", "Organelle") }); | |
| 161 | expect(log.ok).toBe(true); | |
| 162 | }); | |
| 163 | }); |
test/repo-transfer-follow.test.ts +1 −1
| @@ -225,7 +225,7 @@ describe("a moved repository's fork, preview, queued deploy and remotes follow i | ||
| 225 | 225 | const token = mintRunToken(app.db, carol.id, "r1", { repoId: before.id, forkId: fork.id }, "2999-01-01T00:00:00Z").token; |
| 226 | 226 | const headers = { authorization: `Bearer ${token}` }; |
| 227 | 227 | const old = await fetch(`${servers.url}/api/v1/repos/alice/lab`, { headers, redirect: "manual" }); |
| 228 | expect(old.status).toBe(308); | |
| 228 | expect(old.status).toBe(301); | |
| 229 | 229 | expect(old.headers.get("location")).toBe("/api/v1/repos/bob/lab"); |
| 230 | 230 | const there = await fetch(`${servers.url}/api/v1/repos/bob/lab`, { headers }); |
| 231 | 231 | expect(there.status).toBe(200); |
test/repo-transfer-web.test.ts +1 −1
| @@ -127,6 +127,6 @@ describe("moving a repository from Settings", () => { | ||
| 127 | 127 | expect(res.status).toBe(303); |
| 128 | 128 | expect(res.headers.get("location")).toContain("/lab/notes/settings"); |
| 129 | 129 | expect((await get("/lab/notes/settings", "alice")).status).toBe(200); |
| 130 | expect((await get("/alice/notes", "alice")).status).toBe(308); | |
| 130 | expect((await get("/alice/notes", "alice")).status).toBe(301); | |
| 131 | 131 | }); |
| 132 | 132 | }); |
test/repo-transfer.test.ts +7 −7
| @@ -139,18 +139,18 @@ describe("transferring a repository", () => { | ||
| 139 | 139 | expect(JSON.parse(event!.payload)).toMatchObject({ from: "alice/notes", to: "lab/notes" }); |
| 140 | 140 | }); |
| 141 | 141 | |
| 142 | test("the old page, API path, issue and pull answer 308 to the new ones", async () => { | |
| 142 | test("the old page, API path, issue and pull answer 301 to the new ones", async () => { | |
| 143 | 143 | const page = await call("/alice/notes", aliceRw); |
| 144 | expect(page.status).toBe(308); | |
| 144 | expect(page.status).toBe(301); | |
| 145 | 145 | expect(page.headers.get("location")).toBe("/lab/notes"); |
| 146 | 146 | const api = await call("/api/v1/repos/alice/notes?x=1", aliceRw); |
| 147 | expect(api.status).toBe(308); | |
| 147 | expect(api.status).toBe(301); | |
| 148 | 148 | expect(api.headers.get("location")).toBe("/api/v1/repos/lab/notes?x=1"); |
| 149 | 149 | const issue = await call(`/alice/notes/issues/${issueNumber}`, aliceRw); |
| 150 | expect(issue.status).toBe(308); | |
| 150 | expect(issue.status).toBe(301); | |
| 151 | 151 | expect(issue.headers.get("location")).toBe(`/lab/notes/issues/${issueNumber}`); |
| 152 | 152 | const pull = await call(`/api/v1/repos/alice/notes/pulls/${pullNumber}`, aliceRw); |
| 153 | expect(pull.status).toBe(308); | |
| 153 | expect(pull.status).toBe(301); | |
| 154 | 154 | expect(pull.headers.get("location")).toBe(`/api/v1/repos/lab/notes/pulls/${pullNumber}`); |
| 155 | 155 | // The new paths answer. |
| 156 | 156 | expect((await call(`/api/v1/repos/lab/notes/pulls/${pullNumber}`, aliceRw)).status).toBe(200); |
| @@ -177,7 +177,7 @@ describe("transferring a repository", () => { | ||
| 177 | 177 | const hidden = await createRepo(app, alice, { name: "hidden", visibility: "private" }); |
| 178 | 178 | if (!hidden.ok) throw new Error(String(hidden.error)); |
| 179 | 179 | expect((await call("/api/v1/repos/alice/hidden/transfer", aliceRw, "POST", { newOwner: "lab" })).status).toBe(200); |
| 180 | expect((await call("/api/v1/repos/alice/hidden", aliceRw)).status).toBe(308); | |
| 180 | expect((await call("/api/v1/repos/alice/hidden", aliceRw)).status).toBe(301); | |
| 181 | 181 | for (const path of ["/api/v1/repos/alice/hidden", "/alice/hidden"]) { |
| 182 | 182 | const res = await call(path, bobRw); |
| 183 | 183 | expect(res.status).toBe(404); |
| @@ -191,7 +191,7 @@ describe("transferring a repository", () => { | ||
| 191 | 191 | expect((await call("/api/v1/repos/lab/notes/transfer", aliceRw, "POST", { newOwner: "lab2" })).status).toBe(200); |
| 192 | 192 | for (const old of ["alice", "lab"]) { |
| 193 | 193 | const res = await call(`/${old}/notes/issues/${issueNumber}`, aliceRw); |
| 194 | expect(res.status).toBe(308); | |
| 194 | expect(res.status).toBe(301); | |
| 195 | 195 | expect(res.headers.get("location")).toBe(`/lab2/notes/issues/${issueNumber}`); |
| 196 | 196 | } |
| 197 | 197 | expect(transfersOf(app.db, repo.id).map((t) => t.to)).toEqual(["lab/notes", "lab2/notes"]); |
test/tui-transfer-repo.test.ts +14
| @@ -128,6 +128,19 @@ describe("move a repository in the terminal", () => { | ||
| 128 | 128 | h.stop(); |
| 129 | 129 | }); |
| 130 | 130 | |
| 131 | test("typing a new name in the rename prompt renames the repository", async () => { | |
| 132 | const repo = await mkRepo(undefined, "before"); | |
| 133 | const h = open(alice, repo); | |
| 134 | await h.feed(":", ..."Rename this"); | |
| 135 | await until(h, () => h.text().includes("the old address redirects"), "the palette row"); | |
| 136 | await h.feed("enter"); | |
| 137 | await h.feed(..."after", "enter"); | |
| 138 | await until(h, () => h.text().includes("renamed alice/before"), "the rename's receipt"); | |
| 139 | expect(getRepoById(app.db, repo.id)?.name).toBe("after"); | |
| 140 | expect(getRepo(app.db, "alice", "after")?.id).toBe(repo.id); | |
| 141 | h.stop(); | |
| 142 | }); | |
| 143 | ||
| 131 | 144 | test("a refusal is said in the API's words and the repository stays", async () => { |
| 132 | 145 | await mkRepo("lab", "taken"); |
| 133 | 146 | const repo = await mkRepo(undefined, "taken"); |
| @@ -155,6 +168,7 @@ describe("move a repository in the terminal", () => { | ||
| 155 | 168 | } |
| 156 | 169 | const h = open(alice, shop); |
| 157 | 170 | expect(transferRepoCommands(h.session, h.ctx, shop).map((c) => c.title)).toEqual([ |
| 171 | "acme/shop — Rename this repository", | |
| 158 | 172 | "acme/shop — Move this repository to another owner", |
| 159 | 173 | ]); |
| 160 | 174 | h.stop(); |
new above
offline