Merge pull request 'gitea_create_repo creates a repository in an organisation, saying the forge's refusal (issue 1124)' (#362) from fix/1124-create-repo-in-org into main

This commit was merged in pull request #362.
This commit is contained in:
2026-10-18 13:50:31 +00:00
3 changed files with 133 additions and 4 deletions
+12 -2
View File
@@ -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<GiteaRepo> {
return GiteaClient.mapRepo(await this.request<any>("/user/repos", { method: "POST", body: JSON.stringify(data) }));
}, owner?: string): Promise<GiteaRepo> {
const options = { method: "POST", body: JSON.stringify(data) };
if (owner === undefined) return GiteaClient.mapRepo(await this.request<any>("/user/repos", options));
const path = `/orgs/${pathSegment("organisation", owner)}/repos`;
try {
return GiteaClient.mapRepo(await this.request<any>(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<void> {
+117
View File
@@ -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<void>((r) => server.listen(0, "127.0.0.1", () => r()));
after(() => new Promise<void>((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");
});
+4 -2
View File
@@ -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 };
},
},