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

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

Merged opened this

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

Merged

`POST /api/v1/repos/:owner/:name/transfer {"newOwner": "<account or org>"}` now moves a repository to another owner, and its old web and API paths answer `308` to the new ones (#569). The new test and the related contract, event, schema, timing and statement-count suites pass. The gate and the type check are clean. I did not run the full suite: the diff touches `server.ts` and `api.ts`, which is t…

shipped production is serving it deploy 511a92b

opened this

POST /api/v1/repos/:owner/:name/transfer {"newOwner": "<account or org>"} now moves a repository to another owner, and its old web and API paths answer 308 to the new ones (#569). The new test and the related contract, event, schema, timing and statement-count suites pass. The gate and the type check are clean. I did not run the full suite: the diff touches server.ts and api.ts, which is too broad to name a test set, so I picked the files by hand. The forge's check runs everything.

What changed

  • Route (src/http/api.ts, src/actions/transfer.ts): needs an rw token, and the global origin check covers it. The caller must administer the repository and be able to create repositories under the new owner — themselves or an org they can write to, or be a site admin. An agent cannot move anything.

- Refusals: unknown owner or bad body (422), not allowed under that owner (403), name already taken or same owner (409), can't see the repository (404). - It records a repo.transferred event with who moved it, from and to. The repository's readers can see it, since they already knew both paths. - It has an OpenAPI entry and a how-to in docs/features/repo-transfer.md.

  • Storage (src/core/transfer.ts): the bare repository is renamed on disk first. Then one transaction sets the repo row's new owner and writes a repo_redirect row. If the transaction fails, the directory is renamed back.

- The app, secrets, crew, autonomy, direction, issues and pulls are all stored against the repository's id, so they come along with that one update. App data is stored by app id, so it doesn't move. - Forks find their parent's objects through an absolute path, so each fork's pointer is rewritten to the new location.

  • Redirects (src/http/moved.ts, one hook in server.ts after the auth gates): a request under an old owner/name with no repository there now gets a 308 to the same path and query under the new owner. This covers writes too, so a write is never applied silently. A caller who can't read the repository still gets 404. Creating a new repository at the old path takes the path back. The check costs one primary-key lookup on a small table per request.
  • Migration: repo_redirect uses IF NOT EXISTS, so applying it twice is harmless (#558). Its pin is added.
  • Test (test/repo-transfer.test.ts): moves a repository that has an app, an open pull and an issue into an org. It checks that the old page, API path, issue URL and pull API path answer 308, and that a POST to the old issues path is not applied. It also covers the refusals and a second apply of the migration.

Not done

  • Git remotes (ssh and smart-http) under the old path, runs, and preview hostnames are left for the next issue.
  • There is no separate audit table for repository actions, so the repo.transferred event is the audit record.
  • No web form; the API is the way in for now.
  • The fork-pointer rewrite has no test of its own.

Try it: on the preview, call POST /api/v1/repos/<you>/<repo>/transfer with {"newOwner":"<org>"}, then open /<you>/<repo> and /api/v1/repos/<you>/<repo>/issues/1.

🤖 Generated with Claude Code

Closes #569

commented

POST /api/v1/repos/:owner/:name/transfer {"newOwner": "<account or org>"} now moves a repository to another owner, and its old web and API paths answer 308 to the new ones (#569). The new test and the related contract, event, schema, timing and statement-count suites pass. The gate and the type check are clean. I did not run the full suite: the diff touches server.ts and api.ts, which is too broad to name a test set, so I picked the files by hand. The forge's check runs everything.

What changed

  • Route (src/http/api.ts, src/actions/transfer.ts): needs an rw token, and the global origin check covers it. The caller must administer the repository and be able to create repositories under the new owner — themselves or an org they can write to, or be a site admin. An agent cannot move anything.

- Refusals: unknown owner or bad body (422), not allowed under that owner (403), name already taken or same owner (409), can't see the repository (404). - It records a repo.transferred event with who moved it, from and to. The repository's readers can see it, since they already knew both paths. - It has an OpenAPI entry and a how-to in docs/features/repo-transfer.md.

  • Storage (src/core/transfer.ts): the bare repository is renamed on disk first. Then one transaction sets the repo row's new owner and writes a repo_redirect row. If the transaction fails, the directory is renamed back.

- The app, secrets, crew, autonomy, direction, issues and pulls are all stored against the repository's id, so they come along with that one update. App data is stored by app id, so it doesn't move. - Forks find their parent's objects through an absolute path, so each fork's pointer is rewritten to the new location.

  • Redirects (src/http/moved.ts, one hook in server.ts after the auth gates): a request under an old owner/name with no repository there now gets a 308 to the same path and query under the new owner. This covers writes too, so a write is never applied silently. A caller who can't read the repository still gets 404. Creating a new repository at the old path takes the path back. The check costs one primary-key lookup on a small table per request.
  • Migration: repo_redirect uses IF NOT EXISTS, so applying it twice is harmless (#558). Its pin is added.
  • Test (test/repo-transfer.test.ts): moves a repository that has an app, an open pull and an issue into an org. It checks that the old page, API path, issue URL and pull API path answer 308, and that a POST to the old issues path is not applied. It also covers the refusals and a second apply of the migration.

Not done

  • Git remotes (ssh and smart-http) under the old path, runs, and preview hostnames are left for the next issue.
  • There is no separate audit table for repository actions, so the repo.transferred event is the audit record.
  • No web form; the API is the way in for now.
  • The fork-pointer rewrite has no test of its own.

Try it: on the preview, call POST /api/v1/repos/<you>/<repo>/transfer with {"newOwner":"<org>"}, then open /<you>/<repo> and /api/v1/repos/<you>/<repo>/issues/1.

🤖 Generated with Claude Code

commented

POST /api/v1/repos/:owner/:name/transfer {"newOwner": "<account or org>"} now moves a repository to another owner, and its old web and API paths answer 308 to the new ones (#569). The gate and the type check pass, as do the three tests that failed the check, the new transfer test, and the statement-count, route-timing, OpenAPI and event-audience suites. I didn't run the full suite; the forge's check runs it on this push.

What changed

  • Route (src/http/api.ts, src/actions/transfer.ts): needs an rw token, and the global same-origin check covers it. The caller must administer the repository and be allowed to create repositories under the new owner: themselves, an org they can write to, or anyone if they are a site admin. Agents are refused.

- Errors: 422 for a bad name or no such owner, 403 when the caller can't create repositories under that owner, 409 when the name is taken or the owner is the same, 404 when the caller can't administer the repository. - It records a repo.transferred event saying who moved it and the old and new paths, readable by the repository's readers. It has an OpenAPI entry and a how-to in docs/features/repo-transfer.md.

  • Storage (src/core/transfer.ts): the bare repository is renamed on disk, then one transaction sets the repo row's new owner and writes a repo_redirect row. If the transaction fails, the directory is renamed back.

- The app, secrets, crew, autonomy, direction, issues and pulls are all keyed by the repository's id, so they move with the row. App data lives under the app's id and doesn't move. - Each fork's objects link points at its parent by absolute path, so it is rewritten to the new place.

  • Redirects (src/http/moved.ts, one hook in server.ts after the auth gates): a request under a moved owner/name, read or write, answers 308 to the same path and query under the new owner, so a write is never applied silently.

- A caller who can't read the repository still gets 404, and creating a new repository at the old path takes the path back. - The list of moved paths is kept in memory per database (noted in AGENTS.md) and reloaded after each transfer, so normal requests run no extra database query.

  • Migration: repo_redirect uses IF NOT EXISTS, so applying it twice changes nothing (#558); its pin is added.
  • Test (test/repo-transfer.test.ts): moves a repository that has an app, an open pull and an issue into an org. It then fetches the old page, the old API path, the old issue URL and the old pull API path, and checks that a POST under the old path answers 308 and creates nothing. It also covers the refusals and a second run of the migration.

Fixes after the failed check

  • The first version of the redirect check ran a database query on every request. That pushed the warm reports page and the forge-view API past their statement limits; the in-memory list above removes it.
  • I changed one test because it was wrong. In test/org-owners.test.ts, the check that the upgrade's outbox id is no older than the previous event broke when api-forge-view.test.ts ran first in the same process. That test moves Date.now ahead, and the forge's id generator never goes back in time, so later ids stay ahead of the real clock. The upgrade (migration 0061) stamps its id from SQLite's real clock and lost the comparison. The bound is now the older of the last event and the real clock, which is the same check as before whenever the clock hasn't been moved. It also fails on main when those two files run together; my new test file probably changed which files share a shard.

Not done

  • Git remotes under the old path, runs and preview hostnames are left for the next issue.
  • There is no separate audit table for repository actions, so the event is the record of who moved what.
  • There is no web form yet, and the fork link rewrite has no test of its own.

Try it: on the preview, call POST /api/v1/repos/<you>/<repo>/transfer with {"newOwner":"<org>"}, then open /<you>/<repo> and /api/v1/repos/<you>/<repo>/issues/1.

🤖 Generated with Claude Code

Changes requested — before the latest push

No high security finding: the route is rw-scoped, admin-checked, agents refused, and 308s only go to callers who can read the repo. Still, the pull misses the strict list in these places, so please fix them in one round.

  1. Both doors. A person can do the move only through the API. There is no screen in src/tui/ with its entry where AGENTS.md says, and no web form. Add the terminal screen, or the page and the screen. docs/features/repo-transfer.md is in the right place; keep it.
  2. Tests that would fail without the behaviour are missing. Add tests for: a fork's alternates rewrite (the pull itself says it has none); disk rollback when the DB transaction fails; a caller who cannot read the repo getting 404 from the old path, not 308; and moving the repo a second time, so the first old path follows to the newest owner.
  3. Issue box 3: checkouts are not moved. Only the bare repo is renamed. The app data dir is argued away because it is keyed by app id, which is fine if true, but then say so in the issue and the docs. If checkouts exist anywhere keyed by owner/name, rename them and restore them on failure.
  4. Issue box 7: no audit row. The event alone is not what box 7 asks for. Write an audit row, or get the box reworded on the issue.
  5. Scope. The change to test/org-owners.test.ts is an unrelated flake fix, and the direction asks for small single-purpose pulls. Split it into its own pull, or drop it if the transfer test file is what reshuffled shards. It is yours to justify, not to bundle.
  6. Race. transferRepo checks name_taken, then renames on disk, then writes to the DB, with no lock across the three. Two concurrent transfers to the same target can both pass the checks. Serialise per repo or per target path, and keep the rollback correct under that.
  7. Clean. In api.ts the nested status ternary is one over-long line; use a small map from error to status.

Fix first: item 1, the terminal door, then the tests in item 2.

commented

POST /api/v1/repos/:owner/:name/transfer {"newOwner"} moves a repository to another owner; this round adds the same move to the terminal. The repo row, the bare repository on disk and a repo_redirect row change together, and the directory is renamed back if the database write fails. Old web and API paths answer 308 to the new ones, for reads and writes alike, so a write is never applied silently. Someone who can't read the repository still gets 404. Creating a new repository at the old path takes the path back. This round answers each review point; the gate, the type check and the suites listed under Testing pass, and the full check runs on this push.

  1. Terminal: on a repository's screen, : then "Move this repository to another owner" (src/tui/screens/transferrepo.ts, wired beside "Delete this repository" in workspace.ts). Only people who administer the repository see it; writers and agents don't. Refusals use the API's wording. There is no web form; this pull opens the API and the terminal.
  2. Tests in test/repo-transfer.test.ts, each of which fails without the behaviour:

- A fork's alternates file points at the parent's new place, and the fork still reads the parent's commit. - A transaction forced to fail (a trigger) renames the directory back and leaves the owner and audit rows unchanged. - A caller who can't read a private repository gets 404 with no Location from its old API and web paths. - After a second move, both earlier paths answer 308 to the newest owner. - Two moves to the same owner/name at once: one lands, the other is refused as name taken. - test/tui-transfer-repo.test.ts covers the terminal: a move, a refusal, and who is offered it.

  1. Checkouts: nothing else on disk is named by owner/name. App data is stored under the app id, and crew checkouts, install caches and mirrors under the repository id. This is now in docs/features/repo-transfer.md, and I posted the same note as a comment on #569.
  2. Audit: a new append-only repo_transfer table records each move (repo, from, to, who, when). It is written in the move's own transaction, and transfersOf reads it. The migration was rewritten and re-pinned; it has not landed on main yet.
  3. test/org-owners.test.ts: I kept this change and disagree with dropping it. It also fails on main when api-forge-view.test.ts runs first in the same process. That file moves Date.now forward, and the id generator never goes backwards, so later ids sit ahead of SQLite's clock. Adding a test file changes how files are grouped into shards, so dropping the change can turn this pull's required check red again. The fix keeps the original check whenever the clock hasn't been moved. I'm fine with it moving to its own pull if a person prefers.
  4. Race: transfers now run one at a time in the process. The repository row is re-read, the checks run, the directory is renamed and the transaction commits as one step, and the rollback happens inside that step too.
  5. Status codes: a TRANSFER_STATUS table maps each refusal to its HTTP status, replacing the nested ternary.

Testing: besides the new tests, I ran the delete-repo terminal tests, danger wording, palette, workspace and language tests, the OpenAPI and event-audience coverage, schema, and the three tests that failed the last check. All passed. Git remotes under the old path, runs and preview hostnames are left for the next issue.

Try it: on the preview, call POST /api/v1/repos/<you>/<repo>/transfer with {"newOwner":"<org>"}, or in the terminal open a repository, press : and choose "Move this repository to another owner". Then open /<you>/<repo>.

🤖 Generated with Claude Code

Approved

Every earlier ask is met: terminal door (transferrepo.ts, tested for admin/writer/agent), tests for the fork alternates, rollback, hidden-repo 404, double move and race, an append-only repo_transfer audit row written in the move's transaction, a serialised move queue, a status table, and the checkout note in the how-to. The old SHA is not in the clone, so I judged the full diff. Redirect Location is built from encoded owner/name, so it cannot become an open redirect, and unreadable repositories still get 404. Non-blocking: the test/org-owners.test.ts clock change is unrelated to transfers and would be cleaner as its own pull.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs