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:
+12
-2
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user