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

MCP moves a repository to another owner, as `POST …/transfer` does #10152

Merged opened this

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

Merged

scout/forge:scout/10129-mcp-moves-a-repository-to-another-owner → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/10129-mcp-moves-a-repository-to-another-owner was merged into main as fdcfa46.

Review

Sign in to review this change.
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
44 (an admin's, beside Archive): type the new owner, confirm, and you land on
55 Settings at the new path. A refusal comes back beside the field as its
66 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:
outdated

This line is about 130 characters, far past the file wrap width. Rewrap it across lines like the paragraph around it.

9 `repo_settings` with `{ repo, newOwner }` (admin-scope token) answers the same
10 codes and sentences as the API. From the API:
911 `POST /api/v1/repos/:owner/:name/transfer` with `{"newOwner": "<account or org>"}`
1012 moves a repository (an `rw` token; the caller administers the repository and
1113 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 @@
11 import { updateRepoSettings } from "../../../actions/repos.ts";
2 import { TRANSFER_SAID, transferRepository } from "../../../actions/transfer.ts";
23 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
34 import { VISIBILITY_WORDS } from "../../../core/arg_words.ts";
45
56 export const tool: Tool = {
67 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`",
89 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).",
1011 inputSchema: {
1112 type: "object",
1213 properties: {
1314 repo: REPO_ARG,
1415 description: { type: "string", maxLength: 2000 },
1516 visibility: { type: "string", enum: ["public", "private"] },
17 newOwner: { type: "string", maxLength: 64 },
1618 },
1719 required: ["repo"],
1820 },
@@ -21,7 +23,23 @@ export const tool: Tool = {
2123 async run(ctx, user, args) {
2224 const repo = repoArg(ctx, user, args);
2325 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 }
2543 if (description !== undefined && typeof description !== "string")
2644 return bad("invalid", "description is text");
2745 if (visibility !== undefined && visibility !== "public" && visibility !== "private")
test/mcp-reposet.test.ts +16
@@ -88,4 +88,20 @@ describe("MCP repo_settings", () => {
8888 expect(set.isError).toBe(true);
8989 expect(set.text).toContain("visibility is public or private");
9090 });
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 });
91107 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs