The forge's tools close and read pull requests, read files and branches, and delete a branch
Ten tools the console lacked for the actions a review and a merge leave behind: close or reopen a pull request whose work landed elsewhere, change its title or body, read its files, its diff and its comments, reopen an issue, read one file at a ref, list branches, delete the branch a closed pull request leaves. Each is the client's own call; `gitea_api` stays the escape hatch for the rest. Tested against the fake forge through the compiled tools, the way the console calls them (13/13).
This commit is contained in:
@@ -45,6 +45,14 @@ export interface GiteaPull {
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export interface GiteaComment {
|
||||
id: number;
|
||||
user?: string;
|
||||
body: string;
|
||||
created_at?: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export interface GiteaLabel {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -257,6 +265,64 @@ export class GiteaClient {
|
||||
);
|
||||
}
|
||||
|
||||
/** Close or reopen a pull request without merging it. A pull request is an issue to the forge's
|
||||
* state machine, and the pulls endpoint takes the same `state`. */
|
||||
async setPullState(owner: string, repo: string, index: number, state: "open" | "closed"): Promise<GiteaPull> {
|
||||
return GiteaClient.mapPull(
|
||||
await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`, { method: "PATCH", body: JSON.stringify({ state }) }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Change a pull request's title or body; a field left undefined is left alone. */
|
||||
async updatePullRequest(owner: string, repo: string, index: number, data: { title?: string; body?: string }): Promise<GiteaPull> {
|
||||
return GiteaClient.mapPull(
|
||||
await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
);
|
||||
}
|
||||
|
||||
/** The unified diff of a pull request, as text. */
|
||||
async pullDiff(owner: string, repo: string, index: number): Promise<string> {
|
||||
return this.requestText(`/repos/${owner}/${repo}/pulls/${index}.diff`);
|
||||
}
|
||||
|
||||
/** Every comment on an issue or pull request, oldest first. */
|
||||
async listComments(owner: string, repo: string, index: number): Promise<GiteaComment[]> {
|
||||
const raw = await this.request<any[]>(`/repos/${owner}/${repo}/issues/${index}/comments`);
|
||||
return (raw ?? []).map((c) => ({
|
||||
id: Number(c?.id ?? 0),
|
||||
user: c?.user?.login,
|
||||
body: String(c?.body ?? ""),
|
||||
created_at: c?.created_at,
|
||||
html_url: String(c?.html_url ?? ""),
|
||||
}));
|
||||
}
|
||||
|
||||
/** One file's contents at a ref (default the repository's default branch), decoded. */
|
||||
async getFile(owner: string, repo: string, path: string, ref?: string): Promise<{ path: string; ref?: string; sha: string; size: number; content: string }> {
|
||||
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
||||
const f = await this.request<any>(`/repos/${owner}/${repo}/contents/${path.split("/").map(encodeURIComponent).join("/")}${qs}`);
|
||||
if (!f || f.type !== "file") throw new Error(`Gitea API: ${path} is not a file`);
|
||||
const content = f.encoding === "base64" ? Buffer.from(String(f.content ?? ""), "base64").toString("utf8") : String(f.content ?? "");
|
||||
return { path, ref, sha: String(f.sha ?? ""), size: Number(f.size ?? content.length), content };
|
||||
}
|
||||
|
||||
async listBranches(owner: string, repo: string): Promise<{ name: string; commit: string; protected: boolean }[]> {
|
||||
const raw = await this.request<any[]>(`/repos/${owner}/${repo}/branches?limit=100`);
|
||||
return (raw ?? []).map((b) => ({ name: String(b?.name ?? ""), commit: String(b?.commit?.id ?? ""), protected: Boolean(b?.protected) }));
|
||||
}
|
||||
|
||||
async deleteBranch(owner: string, repo: string, branch: string): Promise<void> {
|
||||
await this.request(`/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** A request whose answer is text, not JSON — a diff. Same token handling as request(). */
|
||||
private async requestText(path: string): Promise<string> {
|
||||
const token = await this.tokens.current();
|
||||
const res = await this.send(path, { headers: { Accept: "text/plain" } }, token);
|
||||
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise<void> {
|
||||
await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -31,6 +31,9 @@ interface Forge {
|
||||
tokens: Map<string, string>;
|
||||
scopesOf: Map<string, string[]>;
|
||||
admins: Map<string, string>;
|
||||
pullState: string;
|
||||
pullTitle: string;
|
||||
branchDeleted: boolean;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -41,6 +44,9 @@ function fakeForge(): Promise<Forge> {
|
||||
tokens: new Map<string, string>(), // name -> value
|
||||
scopesOf: new Map<string, string[]>(), // value -> scopes, so a route can enforce them like gitea does
|
||||
admins: new Map([[ADMIN, PASSWORD]]),
|
||||
pullState: "open",
|
||||
pullTitle: "The console shipped",
|
||||
branchDeleted: false,
|
||||
};
|
||||
// write:X implies read:X — gitea's own rule (models/auth/access_token_scope.go).
|
||||
const covers = (scopes: string[], required: string): boolean =>
|
||||
@@ -86,6 +92,37 @@ function fakeForge(): Promise<Forge> {
|
||||
}
|
||||
return json(res, 405, { message: "method not allowed" });
|
||||
}
|
||||
const tokenOf = (): string => {
|
||||
const h = req.headers.authorization ?? "";
|
||||
return h.startsWith("token ") ? h.slice(6) : "";
|
||||
};
|
||||
const pull = url.pathname.match(/^\/api\/v1\/repos\/novox\/hq\/pulls\/(\d+)(\.diff)?$/);
|
||||
if (pull) {
|
||||
if (![...forge.tokens.values()].includes(tokenOf())) return json(res, 401, { message: "token is required" });
|
||||
if (pull[2]) {
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
return res.end("diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new\n");
|
||||
}
|
||||
if (req.method === "PATCH") {
|
||||
const patch = await body(req);
|
||||
forge.pullState = patch?.state ?? forge.pullState;
|
||||
forge.pullTitle = patch?.title ?? forge.pullTitle;
|
||||
}
|
||||
return json(res, 200, { number: Number(pull[1]), title: forge.pullTitle, state: forge.pullState, merged: false,
|
||||
user: { login: "mesh-admin" }, head: { ref: "feat/x" }, base: { ref: "main" }, html_url: "http://fake/novox/hq/pulls/" + pull[1] });
|
||||
}
|
||||
if (url.pathname === "/api/v1/repos/novox/hq/issues/223/comments") {
|
||||
return json(res, 200, [{ id: 1, user: { login: "jochen" }, body: "landed elsewhere", created_at: "2026-10-01T00:00:00Z", html_url: "http://fake/c/1" }]);
|
||||
}
|
||||
if (url.pathname === "/api/v1/repos/novox/hq/contents/README.md") {
|
||||
return json(res, 200, { type: "file", encoding: "base64", sha: "abc", size: 5, content: Buffer.from("hello").toString("base64") });
|
||||
}
|
||||
if (url.pathname === "/api/v1/repos/novox/hq/branches") {
|
||||
return json(res, 200, [{ name: "main", protected: true, commit: { id: "aaaa" } }, { name: "feat/x", protected: false, commit: { id: "bbbb" } }]);
|
||||
}
|
||||
if (url.pathname === "/api/v1/repos/novox/hq/branches/feat%2Fx" || url.pathname === "/api/v1/repos/novox/hq/branches/feat/x") {
|
||||
if (req.method === "DELETE") { forge.branchDeleted = true; return json(res, 204, null); }
|
||||
}
|
||||
if (url.pathname === "/api/v1/repos/search") {
|
||||
// The client lists through the search endpoint since 2026-09-28 (the forge's whole view);
|
||||
// it sits under `repository`, which write:repository covers.
|
||||
@@ -140,6 +177,9 @@ function fakeForge(): Promise<Forge> {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
get mints() { return forge.mints; },
|
||||
get lastScopes() { return forge.lastScopes; },
|
||||
get pullState() { return forge.pullState; },
|
||||
get pullTitle() { return forge.pullTitle; },
|
||||
get branchDeleted() { return forge.branchDeleted; },
|
||||
tokens: forge.tokens,
|
||||
scopesOf: forge.scopesOf,
|
||||
admins: forge.admins,
|
||||
@@ -360,6 +400,16 @@ test("the tools register once there is a way to a token, and the first call mint
|
||||
"gitea_list_repos", "gitea_create_repo", "gitea_delete_repo",
|
||||
"gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_add_comment",
|
||||
"gitea_list_pull_requests", "gitea_get_pull_request", "gitea_create_pull_request", "gitea_merge_pull_request",
|
||||
"gitea_close_pull_request",
|
||||
"gitea_reopen_pull_request",
|
||||
"gitea_update_pull_request",
|
||||
"gitea_pull_request_files",
|
||||
"gitea_pull_request_diff",
|
||||
"gitea_list_comments",
|
||||
"gitea_reopen_issue",
|
||||
"gitea_get_file",
|
||||
"gitea_list_branches",
|
||||
"gitea_delete_branch",
|
||||
"gitea_list_labels", "gitea_create_label",
|
||||
"gitea_api",
|
||||
],
|
||||
@@ -370,3 +420,32 @@ test("the tools register once there is a way to a token, and the first call mint
|
||||
assert.equal(result.repos.length, 1);
|
||||
assert.equal(forge.mints, before + 1);
|
||||
});
|
||||
|
||||
// The forge's tools reach every action a review needs without a checkout and without the API
|
||||
// escape hatch: close a pull request whose work landed elsewhere, read its diff, its comments, a
|
||||
// file, the branches, and delete the branch left behind. Against the fake forge, through the
|
||||
// compiled tools, the way the console calls them.
|
||||
test("a pull request can be closed, read and cleaned up through the tools", async () => {
|
||||
const { env } = await delivered(forge);
|
||||
const tools = collectTools(env).find((c) => c.module === "gitea")!.tools;
|
||||
const tool = (name: string) => tools.find((t) => t.name === name)!;
|
||||
for (const name of ["gitea_close_pull_request", "gitea_reopen_pull_request", "gitea_update_pull_request", "gitea_pull_request_files",
|
||||
"gitea_pull_request_diff", "gitea_list_comments", "gitea_reopen_issue", "gitea_get_file", "gitea_list_branches", "gitea_delete_branch"]) {
|
||||
assert.ok(tool(name), `${name} is not a tool`);
|
||||
}
|
||||
const closed = (await tool("gitea_close_pull_request").run({ owner: "novox", repo: "hq", number: 223 })) as { pull: { state: string } };
|
||||
assert.equal(closed.pull.state, "closed");
|
||||
assert.equal(forge.pullState, "closed");
|
||||
const renamed = (await tool("gitea_update_pull_request").run({ owner: "novox", repo: "hq", number: 223, title: "Superseded" })) as { pull: { title: string } };
|
||||
assert.equal(renamed.pull.title, "Superseded");
|
||||
const diff = (await tool("gitea_pull_request_diff").run({ owner: "novox", repo: "hq", number: 223 })) as { diff: string };
|
||||
assert.match(diff.diff, /^diff --git/);
|
||||
const comments = (await tool("gitea_list_comments").run({ owner: "novox", repo: "hq", number: 223 })) as { comments: { body: string }[] };
|
||||
assert.equal(comments.comments[0].body, "landed elsewhere");
|
||||
const file = (await tool("gitea_get_file").run({ owner: "novox", repo: "hq", path: "README.md" })) as { file: { content: string } };
|
||||
assert.equal(file.file.content, "hello");
|
||||
const branches = (await tool("gitea_list_branches").run({ owner: "novox", repo: "hq" })) as { branches: { name: string }[] };
|
||||
assert.deepEqual(branches.branches.map((b) => b.name), ["main", "feat/x"]);
|
||||
await tool("gitea_delete_branch").run({ owner: "novox", repo: "hq", branch: "feat/x" });
|
||||
assert.equal(forge.branchDeleted, true);
|
||||
});
|
||||
|
||||
@@ -254,6 +254,131 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "gitea_close_pull_request",
|
||||
description: "Close a pull request without merging it — one whose work landed elsewhere, or was abandoned.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the PR number" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
pull: await gitea.setPullState(String(args.owner), String(args.repo), Number(args.number), "closed"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "gitea_reopen_pull_request",
|
||||
description: "Reopen a closed, unmerged pull request.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the PR number" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
pull: await gitea.setPullState(String(args.owner), String(args.repo), Number(args.number), "open"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "gitea_update_pull_request",
|
||||
description: "Change a pull request's title or body; a field not given is left as it is.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the PR number" },
|
||||
title: { type: "string", description: "the new title (optional)" },
|
||||
body: { type: "string", description: "the new body, markdown (optional)" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
pull: await gitea.updatePullRequest(String(args.owner), String(args.repo), Number(args.number), {
|
||||
title: args.title === undefined ? undefined : String(args.title),
|
||||
body: args.body === undefined ? undefined : String(args.body),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "gitea_pull_request_files",
|
||||
description: "The files a pull request changes, as paths from the repository's root (up to 100; says when there are more).",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the PR number" },
|
||||
},
|
||||
run: async (args) => gitea.listPullFiles(String(args.owner), String(args.repo), Number(args.number)),
|
||||
},
|
||||
{
|
||||
name: "gitea_pull_request_diff",
|
||||
description: "A pull request's unified diff, as text — for reviewing it without a checkout.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the PR number" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
diff: await gitea.pullDiff(String(args.owner), String(args.repo), Number(args.number)),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "gitea_list_comments",
|
||||
description: "Every comment on an issue or pull request, oldest first.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the issue or PR number" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
comments: await gitea.listComments(String(args.owner), String(args.repo), Number(args.number)),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "gitea_reopen_issue",
|
||||
description: "Reopen a closed issue.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the issue number" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
issue: await gitea.setIssueState(String(args.owner), String(args.repo), Number(args.number), "open"),
|
||||
}),
|
||||
},
|
||||
|
||||
// ---- Contents and branches ----
|
||||
{
|
||||
name: "gitea_get_file",
|
||||
description: "One file's contents from a repository, decoded, at a branch, tag or commit (default the repository's default branch).",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
path: { type: "string", description: "the file's path from the repository's root" },
|
||||
ref: { type: "string", description: "branch, tag or commit (optional)" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
file: await gitea.getFile(String(args.owner), String(args.repo), String(args.path), args.ref ? String(args.ref) : undefined),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "gitea_list_branches",
|
||||
description: "Every branch of a repository with the commit it points at.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
},
|
||||
run: async (args) => ({ branches: await gitea.listBranches(String(args.owner), String(args.repo)) }),
|
||||
},
|
||||
{
|
||||
name: "gitea_delete_branch",
|
||||
description: "Delete a branch — a feature branch whose pull request was closed rather than merged. Refused by the forge for a protected branch.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
branch: { type: "string", description: "the branch name" },
|
||||
},
|
||||
run: async (args) => {
|
||||
await gitea.deleteBranch(String(args.owner), String(args.repo), String(args.branch));
|
||||
return { deleted: true, branch: String(args.branch) };
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Labels ----
|
||||
{
|
||||
name: "gitea_list_labels",
|
||||
|
||||
Reference in New Issue
Block a user