diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 76e6a285..9aedbbad 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -244,13 +244,23 @@ export class GiteaClient { return all; } + /** A repository, created under the tools' own user, or with `owner` in that organisation (novox/hq issue 1124). + * The forge's refusal to create in an organisation (403 not allowed, 404 no such organisation, 409 exists) is + * thrown with its status and its reason, naming the organisation. */ async createRepo(data: { name: string; description?: string; private?: boolean; auto_init?: boolean; - }): Promise { - return GiteaClient.mapRepo(await this.request("/user/repos", { method: "POST", body: JSON.stringify(data) })); + }, owner?: string): Promise { + const options = { method: "POST", body: JSON.stringify(data) }; + if (owner === undefined) return GiteaClient.mapRepo(await this.request("/user/repos", options)); + const path = `/orgs/${pathSegment("organisation", owner)}/repos`; + try { + return GiteaClient.mapRepo(await this.request(path, options)); + } catch (e) { + throw new Error(`the forge did not create ${owner}/${data.name} in the organisation ${owner}: ${(e as Error).message}`); + } } async deleteRepo(owner: string, repo: string): Promise { diff --git a/modules/gitea/test/create-repo.test.ts b/modules/gitea/test/create-repo.test.ts new file mode 100644 index 00000000..f7a464c5 --- /dev/null +++ b/modules/gitea/test/create-repo.test.ts @@ -0,0 +1,117 @@ +// A repository is created where it is asked for (novox/hq issue 1124): gitea_create_repo created under the tools' +// own user alone, so a repository of the novox organisation could not be made through it. With `owner` it is created +// in that organisation (POST /orgs/{org}/repos), the forge's refusal said with its status and its reason; without +// it, under the tools' own user as before. + +import { test, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + +import { GiteaClient } from "../dist/client.js"; +import { ConfiguredToken } from "../dist/token.js"; +import { getGiteaTools } from "../dist/tools/index.js"; + +interface Sent { method: string; path: string; body: any } + +const state = { + sent: [] as Sent[], + // When set, what the forge answers a create in an organisation: its status and its message. + refuse: null as { status: number; message: string } | null, +}; + +function json(res: ServerResponse, status: number, body: unknown) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(body)); +} + +const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString(); + const path = new URL(req.url ?? "", "http://x").pathname.replace(/^\/api\/v1/, ""); + const method = req.method ?? "GET"; + state.sent.push({ method, path, body: raw ? JSON.parse(raw) : undefined }); + const created = (owner: string) => { + const body = raw ? JSON.parse(raw) : {}; + return json(res, 201, { full_name: `${owner}/${body.name}`, name: body.name, owner: { login: owner }, + private: body.private, description: body.description, html_url: `http://forge/${owner}/${body.name}`, + clone_url: `http://forge/${owner}/${body.name}.git`, default_branch: "main" }); + }; + if (method === "POST" && path === "/user/repos") return created("agent"); + const org = /^\/orgs\/([^/]+)\/repos$/.exec(path); + if (method === "POST" && org) { + if (state.refuse) return json(res, state.refuse.status, { message: state.refuse.message, url: "http://forge/api/swagger" }); + return created(decodeURIComponent(org[1])); + } + return json(res, 404, { message: "not found" }); +}); +await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); +after(() => new Promise((r) => server.close(() => r()))); +const { port } = server.address() as { port: number }; +const client = new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("t")); +const tool = (name: string) => getGiteaTools(client).find((t) => t.name === name)!; +const body = { name: "mesh-delivery", description: "the delivery's own repository", private: true, auto_init: true }; + +beforeEach(() => { + state.sent = []; + state.refuse = null; +}); + +test("with an organisation the repository is created there, with the same body, and answered (1124/AC1)", async () => { + // Fails before the fix: createRepo takes no owner and posts to /user/repos, so the repository is the tools' user's. + const repo = await client.createRepo({ ...body }, "novox"); + assert.deepEqual(state.sent, [{ method: "POST", path: "/orgs/novox/repos", body }]); + assert.equal(repo.full_name, "novox/mesh-delivery"); + assert.equal(repo.owner, "novox"); + + // The tool passes its owner through; before the fix it has no owner argument and posts to /user/repos. + state.sent = []; + const answered = await tool("gitea_create_repo").run({ owner: "novox", ...body }) as { repo: { full_name: string } }; + assert.deepEqual(state.sent, [{ method: "POST", path: "/orgs/novox/repos", body }]); + assert.equal(answered.repo.full_name, "novox/mesh-delivery"); + assert.match(tool("gitea_create_repo").description, /organisation/, "the description says where it creates"); +}); + +test("without an owner the repository is created under the tools' own user, as before (1124/AC2)", async () => { + // Holds before the fix: it is the behaviour the fix must leave unchanged. + const repo = await client.createRepo({ ...body }); + assert.deepEqual(state.sent, [{ method: "POST", path: "/user/repos", body }]); + assert.equal(repo.full_name, "agent/mesh-delivery"); + + state.sent = []; + await tool("gitea_create_repo").run({ name: "mesh-delivery" }); + assert.deepEqual(state.sent, [{ method: "POST", path: "/user/repos", body: { name: "mesh-delivery", private: true, auto_init: true } }]); +}); + +test("the forge's refusal to create in an organisation is said with its status, its reason and the organisation (1124/AC3)", async () => { + // Fails before the fix: the create goes to /user/repos, which the fake forge answers 201, so nothing is refused. + for (const [status, message] of [ + [403, "user should be an owner or a member of the organisation"], + [404, "GetOrgByName: org does not exist [name: novox]"], + [409, "The repository with the same name already exists."], + ] as const) { + state.refuse = { status, message }; + for (const run of [ + () => client.createRepo({ ...body }, "novox"), + () => tool("gitea_create_repo").run({ owner: "novox", ...body }), + ]) { + await assert.rejects(run(), (e: Error) => { + assert.match(e.message, new RegExp(`\\b${status}\\b`), `the status ${status} is said`); + assert.ok(e.message.includes(message), `the forge's reason is said: ${e.message}`); + assert.match(e.message, /organisation novox/, "the organisation is named"); + return true; + }); + } + } +}); + +test("an owner that is not a name is refused before anything is sent (1124/AC4)", async () => { + // Holds before the fix too: namesChecked (tools/index.ts) checks args.owner for every tool. It stays so the guard + // keeps covering this tool's new argument. + for (const owner of ["..", "novox/../agent", "", "no vox", "%2e%2e", "-x"]) { + await assert.rejects(tool("gitea_create_repo").run({ owner, name: "mesh-delivery" }), /not a repository owner/, + JSON.stringify(owner)); + } + assert.deepEqual(state.sent, [], "nothing reached the forge"); +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 37fd2a6f..772a124e 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -82,8 +82,10 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }, { name: "gitea_create_repo", - description: "Create a repository owned by the authenticated user.", + description: "Create a repository: with owner, in that organisation (the forge's refusal said with its status " + + "and reason); without owner, under the tools' own user.", input: { + owner: { type: "string", description: "an organisation to create the repository in (default: the tools' own user)" }, name: { type: "string", description: "the repository name" }, description: { type: "string", description: "an optional description" }, private: { type: "boolean", description: "private repo (default true)" }, @@ -95,7 +97,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini description: args.description ? String(args.description) : undefined, private: args.private === undefined ? true : Boolean(args.private), auto_init: args.auto_init === undefined ? true : Boolean(args.auto_init), - }); + }, args.owner === undefined ? undefined : String(args.owner)); return { repo }; }, },