Public: anyone can read this; the crew can change it.
MCP moves a repository to another owner, as `POST …/transfer` does #10152
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10129-mcp-moves-a-repository-to-another-owner → main 3 commits fork 1 thread · 1 unresolved
3 files
changed +41
−5
docs/features/repo-transfer.md +4 −2
| @@ -4,8 +4,10 @@ From the web: a repository's Settings page has a "Move to another owner" block | ||
| 4 | 4 | (an admin's, beside Archive): type the new owner, confirm, and you land on |
| 5 | 5 | Settings at the new path. A refusal comes back beside the field as its |
| 6 | 6 | sentence, the field kept; anyone who does not administer the repository gets |
| 7 | `404`. From the terminal: on a repository's screen, `:` then "Move this repository | |
| 8 | to another owner", and type the new owner. From the API: | |
| 7 | `404`. From the terminal: on a repository's screen, `:` then "Move this | |
| 8 | repository to another owner", and type the new owner. From MCP: | |
| 9 | `repo_settings` with `{ repo, newOwner }` (admin-scope token) answers the same | |
| 10 | codes and sentences as the API. From the API: | |
| 9 | 11 | `POST /api/v1/repos/:owner/:name/transfer` with `{"newOwner": "<account or org>"}` |
| 10 | 12 | moves a repository (an `rw` token; the caller administers the repository and |
| 11 | 13 | may create repositories under the new owner — themselves, or an org they can |
src/http/mcp/tools/repo_settings.ts +21 −3
| @@ -1,18 +1,20 @@ | ||
| 1 | 1 | import { updateRepoSettings } from "../../../actions/repos.ts"; |
| 2 | import { TRANSFER_SAID, transferRepository } from "../../../actions/transfer.ts"; | |
| 2 | 3 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 3 | 4 | import { VISIBILITY_WORDS } from "../../../core/arg_words.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| 6 | 7 | name: "repo_settings", |
| 7 | doc: "`{ repo, description?, visibility? }` (admin): 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`", | |
| 8 | doc: "`{ 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`", | |
| 8 | 9 | description: |
| 9 | "Read or change a repo's description or visibility: PATCH /api/v1/repos/:owner/:name (admin).", | |
| 10 | "Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin).", | |
| 10 | 11 | inputSchema: { |
| 11 | 12 | type: "object", |
| 12 | 13 | properties: { |
| 13 | 14 | repo: REPO_ARG, |
| 14 | 15 | description: { type: "string", maxLength: 2000 }, |
| 15 | 16 | visibility: { type: "string", enum: ["public", "private"] }, |
| 17 | newOwner: { type: "string", maxLength: 64 }, | |
| 16 | 18 | }, |
| 17 | 19 | required: ["repo"], |
| 18 | 20 | }, |
| @@ -21,7 +23,23 @@ export const tool: Tool = { | ||
| 21 | 23 | async run(ctx, user, args) { |
| 22 | 24 | const repo = repoArg(ctx, user, args); |
| 23 | 25 | if (isAnswer(repo)) return repo; |
| 24 | const { description, visibility } = args; | |
| 26 | const { description, visibility, newOwner } = args; | |
| 27 | if (newOwner !== undefined) { | |
| 28 | if (typeof newOwner !== "string" || newOwner.length === 0 || newOwner.length > 64) | |
| 29 | return bad("bad_new_owner", "newOwner is an account or organisation name"); | |
| 30 | if (description !== undefined || visibility !== undefined) | |
| 31 | return bad("invalid", "newOwner moves the repository; give it alone"); | |
| 32 | const moved = await transferRepository(ctx.app, user, repo, newOwner); | |
| 33 | if (!moved.ok) | |
| 34 | return moved.error === "not_found" | |
| 35 | ? bad("not_found", "no such app or repository (owner/name)") | |
| 36 | : bad(moved.error, TRANSFER_SAID[moved.error]); | |
| 37 | return good({ | |
| 38 | repo: `${moved.value.ownerName}/${moved.value.name}`, | |
| 39 | description: moved.value.description, | |
| 40 | visibility: moved.value.visibility, | |
| 41 | }); | |
| 42 | } | |
| 25 | 43 | if (description !== undefined && typeof description !== "string") |
| 26 | 44 | return bad("invalid", "description is text"); |
| 27 | 45 | if (visibility !== undefined && visibility !== "public" && visibility !== "private") |
test/mcp-reposet.test.ts +16
| @@ -88,4 +88,20 @@ describe("MCP repo_settings", () => { | ||
| 88 | 88 | expect(set.isError).toBe(true); |
| 89 | 89 | expect(set.text).toContain("visibility is public or private"); |
| 90 | 90 | }); |
| 91 | ||
| 92 | test("newOwner moves a repository; a non-admin and a missing owner are refused", async () => { | |
| 93 | const miss = await call(admin, "repo_settings", { repo: "acme/game", newOwner: "nobody-here" }); | |
| 94 | expect(miss.isError).toBe(true); | |
| 95 | expect(miss.text).toContain("unknown_owner"); | |
| 96 | const denied = await call(writer, "repo_settings", { repo: "acme/game", newOwner: "bob" }); | |
| 97 | expect(denied.isError).toBe(true); | |
| 98 | expect(denied.text).toContain("not_found"); | |
| 99 | const moved = await call(admin, "repo_settings", { repo: "acme/game", newOwner: "alice" }); | |
| 100 | expect(moved.isError).toBe(false); | |
| 101 | expect(JSON.parse(moved.text).repo).toBe("alice/game"); | |
| 102 | const read = await call(admin, "repo", { repo: "alice/game" }); | |
| 103 | expect(JSON.parse(read.text)).toMatchObject({ visibility: "private" }); | |
| 104 | const again = await call(admin, "repo_settings", { repo: "alice/game", newOwner: "alice" }); | |
| 105 | expect(again.text).toContain("same_owner"); | |
| 106 | }); | |
| 91 | 107 | }); |
This line is about 130 characters, far past the file wrap width. Rewrap it across lines like the paragraph around it.