From 171f8a03f64fdce7b21508e89b014d112540252a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 1 Oct 2026 11:25:34 +0200 Subject: [PATCH] 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). --- modules/gitea/client.ts | 66 ++++++++++++++++ modules/gitea/test/token.test.ts | 79 +++++++++++++++++++ modules/gitea/tools/index.ts | 125 +++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index aa86e75..160a41e 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -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 { + return GiteaClient.mapPull( + await this.request(`/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 { + return GiteaClient.mapPull( + await this.request(`/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 { + 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 { + const raw = await this.request(`/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(`/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(`/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 { + 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 { + 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 { await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, { method: "POST", diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index 75a5bd2..42e8186 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -31,6 +31,9 @@ interface Forge { tokens: Map; scopesOf: Map; admins: Map; + pullState: string; + pullTitle: string; + branchDeleted: boolean; close(): Promise; } @@ -41,6 +44,9 @@ function fakeForge(): Promise { tokens: new Map(), // name -> value scopesOf: new Map(), // 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 { } 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 { 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); +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index fda1190..51932f8 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -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",