// gitea's tools — moved here from the shared sdk (novox/hq ADR 0039), importing gitea's own client. // They return structured data; the mesh serves them through the sdk's tool harness. // // One tool emits an event at the natural point of the action it takes (novox/hq ADR 0041/0042): // create-issue emits issue.opened. pull.merged and repo.created are deliberately NOT emitted here: a // merge or a repository is as often made in the web UI or by a plain API call as by these tools, so the // events entrypoint (index.ts) owns both by polling, which catches every path. Announcing a merge here // as well announced every merge made through this tool twice — the tool's at once, the poll's moments // later (novox/hq issue 250). import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { emit } from "@novox/mesh-sdk/events"; import { GiteaClient, type BranchProtection } from "../client.js"; import { appendNote, deliveryStatus, run, viewBody, viewComment } from "../delivery.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { MERGE_METHODS, Refused, checkName, judgeApiCall, judgeContentsWrite, judgeDelete, judgeProtectionChange, pathSegments, routedPath, sentQuery, ruleCovers, } from "../guard.js"; /** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */ const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/; /** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */ const forgeContainer = process.env.MESH_GITEA_CONTAINER || "gitea"; /** A protection rule as a person reads it: what it guards, not every field the forge keeps. */ export function summarised(p: BranchProtection) { return { rule: p.rule_name ?? p.branch_name, push: p.enable_push ?? false, required_statuses: p.enable_status_check ? (p.status_check_contexts ?? []) : [], required_approvals: p.required_approvals ?? 0, admin_may_override: !(p.block_admin_merge_override ?? false), }; } /** Coerce a comma-separated label string into names; empty/absent yields none. */ function parseLabels(raw: unknown): string[] { if (raw === undefined || raw === null || raw === "") return []; return String(raw) .split(",") .map((s) => s.trim()) .filter(Boolean); } /** Every tool's repository owner and name checked before it runs: a name is never another path (guard.ts). */ function namesChecked(tools: ToolDefinition[]): ToolDefinition[] { return tools.map((t) => ({ ...t, run: async (args: Record) => { if (args.owner !== undefined) checkName("owner", args.owner); if (args.repo !== undefined) checkName("name", args.repo); if ((t.name === "gitea_create_repo" || t.name === "gitea_delete_repo") && args.name !== undefined) { checkName("name", args.name); } return t.run(args); }, })); } export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] { return namesChecked(allGiteaTools(gitea)); } function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { return [ // ---- Repositories ---- { name: "gitea_list_repos", description: "List repositories for the authenticated Gitea user.", input: { page: { type: "number", description: "page number (default 1)" }, limit: { type: "number", description: "how many per page (default 20)" }, }, run: async (args) => ({ repos: await gitea.listRepos(args.page ? Number(args.page) : 1, args.limit ? Number(args.limit) : 20), }), }, { name: "gitea_create_repo", description: "Create a repository owned by the authenticated user.", input: { name: { type: "string", description: "the repository name" }, description: { type: "string", description: "an optional description" }, private: { type: "boolean", description: "private repo (default true)" }, auto_init: { type: "boolean", description: "initialise with a README (default true)" }, }, run: async (args) => { const repo = await gitea.createRepo({ name: String(args.name), 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), }); return { repo }; }, }, { name: "gitea_delete_repo", description: "Delete a repository of the tools' own user that no protection rule guards and that had no commit " + "in 30 days. Destructive and irreversible — confirm must be the repository's full name, owner/name. An " + "organisation's or a person's repository is the operator's to delete, at the forge (novox/hq ADR 0266).", input: { owner: { type: "string", description: "the repository owner" }, name: { type: "string", description: "the repository name" }, confirm: { type: "string", description: "the repository's full name, owner/name, to actually delete it" }, }, run: async (args) => { const owner = String(args.owner), name = String(args.name), full = `${owner}/${name}`; const self = (await gitea.api<{ login?: string }>("/user"))?.login ?? ""; const commits = await gitea.api>( `/repos/${owner}/${name}/commits?limit=1`).catch(() => []); const when = commits?.[0]?.commit?.committer?.date ?? commits?.[0]?.created; const refused = judgeDelete(full, args.confirm, owner, self, await gitea.branchProtections(owner, name), when ? new Date(when) : null, new Date()); if (refused) throw new Refused(refused); await gitea.deleteRepo(String(args.owner), String(args.name)); return { deleted: true, repo: `${String(args.owner)}/${String(args.name)}` }; }, }, // ---- Issues ---- { name: "gitea_list_issues", description: "List issues for a repository, filterable by state and labels.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, state: { type: "string", description: "open | closed | all (default open)" }, labels: { type: "string", description: "comma-separated label names to filter by" }, page: { type: "number", description: "page number (default 1)" }, }, run: async (args) => { const params: Record = { state: args.state ? String(args.state) : "open", page: String(args.page ? Number(args.page) : 1), }; if (args.labels) params.labels = String(args.labels); return { issues: await gitea.listIssues(String(args.owner), String(args.repo), params) }; }, }, { name: "gitea_get_issue", description: "Get a single issue by its number.", 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.getIssue(String(args.owner), String(args.repo), Number(args.number)), }), }, { name: "gitea_create_issue", description: "Open a new issue. Label names are resolved to ids, creating any that are missing.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, title: { type: "string", description: "the issue title" }, body: { type: "string", description: "the issue body (markdown)" }, labels: { type: "string", description: "comma-separated label names" }, }, run: async (args) => { const owner = String(args.owner); const repo = String(args.repo); const names = parseLabels(args.labels); const labelIds = names.length ? await Promise.all(names.map((n) => gitea.getOrCreateLabel(owner, repo, n))) : undefined; const issue = await gitea.createIssue(owner, repo, { title: String(args.title), body: args.body ? String(args.body) : undefined, labels: labelIds, }); // The mesh just opened an issue — announce it the moment it exists. await emit("issue.opened", { owner, repo, number: issue.number, title: issue.title, user: issue.user, html_url: issue.html_url, }); return { issue }; }, }, { name: "gitea_close_issue", description: "Close an open 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), "closed"), }), }, { name: "gitea_add_comment", description: "Add a comment to an issue or pull request.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, number: { type: "number", description: "the issue or PR number" }, body: { type: "string", description: "the comment body (markdown)" }, }, run: async (args) => ({ comment: await gitea.addComment(String(args.owner), String(args.repo), Number(args.number), String(args.body)), }), }, // ---- Pull requests ---- { name: "gitea_list_pull_requests", description: "List pull requests for a repository.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, state: { type: "string", description: "open | closed | all (default open)" }, page: { type: "number", description: "page number (default 1)" }, limit: { type: "number", description: "how many per page (default 20)" }, }, run: async (args) => ({ pulls: await gitea.listPullRequests(String(args.owner), String(args.repo), { state: args.state ? String(args.state) : "open", page: String(args.page ? Number(args.page) : 1), limit: String(args.limit ? Number(args.limit) : 20), }), }), }, { name: "gitea_get_pull_request", description: "Get a single pull request by its number.", 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.getPullRequest(String(args.owner), String(args.repo), Number(args.number)), }), }, { name: "gitea_create_pull_request", description: "Open a pull request from a head branch into a base branch.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, title: { type: "string", description: "the PR title" }, body: { type: "string", description: "the PR body (markdown)" }, head: { type: "string", description: "the source branch" }, base: { type: "string", description: "the target branch (default main)" }, }, run: async (args) => ({ pull: await gitea.createPullRequest(String(args.owner), String(args.repo), { title: String(args.title), body: args.body ? String(args.body) : undefined, head: String(args.head), base: args.base ? String(args.base) : "main", }), }), }, { name: "gitea_merge_pull_request", description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head " + "and every status its base branch's protection requires succeeded there (mesh/merge-gate, mesh/repo-check), " + "optionally deleting the source branch afterwards. Refused otherwise, with nothing merged: no tool merges past " + "a check, and none merges a head that moved after it was reviewed.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, number: { type: "number", description: "the PR number" }, head: { type: "string", description: "the commit you reviewed, as its sha or at least its first 12 characters: " + "refused unless it is the pull request's head" }, method: { type: "string", description: "merge (default) | fast-forward-only | squash | rebase | rebase-merge. " + "Only merge and fast-forward-only can be read back to prove the head you named is what landed" }, delete_branch: { type: "boolean", description: "delete the source branch after merge (default true)" }, }, run: async (args) => { const owner = String(args.owner); const repo = String(args.repo); const number = Number(args.number); const method = args.method ? String(args.method) : "merge"; if (!MERGE_METHODS.has(method)) throw new Refused(`${method} is not a merge method: ${[...MERGE_METHODS].join(", ")}`); const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch); // **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here rather than left // to the forge, whose administrator — the account this tool's token is minted for — may merge past a // rule that lets it. A rule that cannot be read is no rule passed. // **Only the head that was reviewed** (novox/hq issue 381): mesh-controller#197 was reviewed at 065cfa0d, // force-pushed to 3ced96f, checked green there, and merged at 3ced96f, because this tool merged whatever the // head was and nothing told it which head the review had approved. The caller names it; a head that moved // since is refused, and the forge is handed the same head, so a push between this read and the merge is // refused by the forge too. const named = args.head === undefined ? "" : String(args.head).trim().toLowerCase(); if (!named) { throw new Refused(`${owner}/${repo}#${number} is not merged: name the head you reviewed (head), so that only ` + `that commit can land (novox/hq issue 381). Nothing was merged`); } // Twelve characters, not seven: an author who force-pushes can make a commit whose first seven match the // reviewed one in seconds, and the review would then merge it. Twelve cannot be matched that way. if (!REVIEWED_HEAD.test(named)) { throw new Refused(`${owner}/${repo}#${number} is not merged: head ${named} does not name one commit — give its ` + `sha, or at least its first 12 characters. Nothing was merged`); } const pull = await gitea.getPullRequest(owner, repo, number); if (pull.merged) throw new Refused(`${owner}/${repo}#${number} is already merged`); if (!pull.head_sha || !pull.base) throw new Refused(`${owner}/${repo}#${number} names no head commit or base branch`); if (!pull.head_sha.toLowerCase().startsWith(named)) { throw new Refused(`${owner}/${repo}#${number} is not merged: its head is ${pull.head_sha.slice(0, 12)}, not the ` + `${named} you named. If you reviewed an earlier head, review the one it has now, then name it. Nothing was merged`); } const rule = await gitea.branchProtection(owner, repo, pull.base); // Only into a branch whose protection requires the checks: a pull request whose base was moved to a // branch nothing guards would merge past every check (novox/hq ADR 0266). if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) { throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule requiring ` + `the checks guards: a tool merges only into such a branch (novox/hq ADR 0266). Nothing was merged`); } const statuses = newestByContext((await gitea.combinedStatus(owner, repo, pull.head_sha)).statuses); const verdict = mergeableUnder(pull.base, rule, statuses); if (verdict.mergeable !== true) { throw new Refused(`${owner}/${repo}#${number} is not merged: ${verdict.says}. A pull request merges once ` + `its checks succeed; no tool merges past them (novox/hq ADR 0266)`); } await gitea.mergePullRequest(owner, repo, number, method, deleteBranch, pull.head_sha); // The merge commit only exists now; answered so the caller can follow what is built from it. // pull.merged is the events entrypoint's to announce (index.ts), once, within its poll. // From here the merge has happened. Nothing below may read as if it had not: a read-back that fails or cannot // be done is said beside merged: true, and only a proven other head is an error — one that says the merge happened. const answer: Record = { merged: true, number, method, head: pull.head_sha, deleted_branch: deleteBranch }; let landed: string | undefined; try { const merged = await gitea.getPullRequest(owner, repo, number); answer.merge_commit_sha = merged.merge_commit_sha; // What landed, read back from the commit the merge made rather than from the forge's record of the pull // request, which is the very thing a race would leave behind. A merge commit's second parent is the head it // joined; a fast-forward lands the head itself. A squash or a rebase makes new commits that name no head. if (!merged.merge_commit_sha) { answer.read_back = "the forge named no merge commit, so what landed cannot be read back"; } else if (method === "merge") { const parents = await gitea.commitParents(owner, repo, merged.merge_commit_sha); if (parents.length === 2) landed = parents[1]; else answer.read_back = `the merge commit has ${parents.length} parent(s), not two: what landed cannot be read back`; } else if (method === "fast-forward-only") { // That the forge names the head itself as a fast-forward's merge commit is assumed, not seen on this forge: // a different sha is said, not raised as another head having landed. if (merged.merge_commit_sha === pull.head_sha) landed = merged.merge_commit_sha; else answer.read_back = `the forge named ${merged.merge_commit_sha.slice(0, 12)} as this fast-forward's commit, ` + `not the head: that the head landed could not be confirmed`; } else { answer.read_back = `a ${method} makes new commits that name no head: what landed cannot be read back`; } } catch (err) { answer.read_back = `the merge happened, but reading back what landed failed: ${(err as Error).message}`; } // Loud, not a field beside merged: true that a caller reading only that would miss. The merge cannot be taken // back by this tool; what must not happen is that it reads as the merge that was asked for. if (landed !== undefined && landed !== pull.head_sha) { throw new Error(`${owner}/${repo}#${number} WAS merged (${answer.merge_commit_sha}), but the forge merged ` + `${landed.slice(0, 12)}, not the ${pull.head_sha.slice(0, 12)} it was handed and you reviewed: a commit nobody ` + `reviewed has landed. Read it before anything is built from it (novox/hq issue 381)`); } if (landed !== undefined) answer.landed = landed; return answer; }, }, { 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) }; }, }, // ---- Branch protection (novox/hq ADR 0237) ---- { name: "gitea_branch_protection_get", description: "A repository's branch protection: the rule for one branch (null when it has none) or every rule — whether direct pushes are refused, which commit statuses a pull request must have succeeded to merge (e.g. mesh/merge-gate), approvals, and whether an administrator may merge past them.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, branch: { type: "string", description: "the branch (rule name); every rule when not given" }, }, run: async (args) => { const owner = String(args.owner), repo = String(args.repo); if (!args.branch) return { rules: (await gitea.branchProtections(owner, repo)).map(summarised) }; const rule = await gitea.branchProtection(owner, repo, String(args.branch)); return { branch: String(args.branch), rule: rule ? summarised(rule) : null }; }, }, { name: "gitea_branch_protection_set", description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, branch: { type: "string", description: "the branch to protect, e.g. main" }, status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/merge-gate,mesh/repo-check; empty requires none" }, push: { type: "boolean", description: "whether a person may push to the branch directly (a new rule refuses it when not given)" }, block_admin_override: { type: "boolean", description: "stop an administrator merging past a status that has not succeeded (left as it is when not given)" }, }, run: async (args) => { const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim(); if (!branch) throw new Error("name the branch to protect"); pathSegments("branch", branch); // a rule's name is a branch's, never a path's const before = await gitea.branchProtection(owner, repo, branch); const want = { statusChecks: String(args.status_checks ?? "").split(","), push: args.push === undefined ? undefined : Boolean(args.push), blockAdminOverride: args.block_admin_override === undefined ? undefined : Boolean(args.block_admin_override), }; const covering = before ? [] : (await gitea.branchProtections(owner, repo)) .filter((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), branch)); const weakens = judgeProtectionChange(before, want, covering); if (weakens) throw new Refused(weakens); const { created, rule } = await gitea.setBranchProtection(owner, repo, branch, want); return { branch, created, before: before ? summarised(before) : null, after: summarised(rule) }; }, }, // ---- A delivery's note, view and statuses (novox/hq ADR 0239), asked by mesh-delivery ---- { name: "gitea_note_append", description: "Append one line to a commit's git note under refs/notes/ (mesh-plan for a delivery), written in the forge's own repository as the forge's own account; a line the note already holds is not added again. `git log --notes=mesh-plan` shows it.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, sha: { type: "string", description: "the commit" }, ref: { type: "string", description: "the notes ref's name (default mesh-plan)" }, line: { type: "string", description: "the line, one line" }, }, run: async (args) => { const said = await appendNote(run, forgeContainer, String(args.owner), String(args.repo), String(args.sha), String(args.ref ?? "mesh-plan") || "mesh-plan", String(args.line ?? "")); return { commit: String(args.sha), ...said }; }, }, { name: "gitea_delivery_view", description: "Keep a delivery's view on its pull request: one comment, marked as the delivery's, created the first time and edited in place after — the page every status of the delivery links to.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, number: { type: "number", description: "the pull request's number" }, body: { type: "string", description: "the view, markdown" }, }, run: async (args) => { const owner = String(args.owner), repo = String(args.repo), number = Number(args.number); const body = viewBody(String(args.body ?? "")); const existing = viewComment(await gitea.listComments(owner, repo, number)); if (existing) return { edited: await gitea.editComment(owner, repo, existing.id, body) }; return { created: await gitea.addComment(owner, repo, number, body) }; }, }, { name: "gitea_commit_statuses", description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, number: { type: "number", description: "a pull request's number: its head is read, and its base's protection" }, sha: { type: "string", description: "a commit, or the start of one, instead of a pull request" }, base: { type: "string", description: "with sha: the branch whose protection is read (default main)" }, }, run: async (args) => { const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim(); const number = args.number === undefined || args.number === "" ? 0 : Number(args.number); let ref = String(args.sha ?? "").trim(); let base = String(args.base ?? "").trim(); if (!owner || !repo) throw new Error("name the repository: owner and repo"); if (!Number.isInteger(number) || number < 0) throw new Error(`${args.number} is not a pull request's number`); let pull: { number: number; title: string; state: string; merged: boolean; base?: string; head?: string; head_sha?: string; merge_commit_sha?: string; html_url: string } | undefined; if (number > 0) { const p = await gitea.getPullRequest(owner, repo, number); pull = { number: p.number, title: p.title, state: p.state, merged: p.merged, base: p.base, head: p.head, head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, html_url: p.html_url }; if (!ref) ref = p.head_sha ?? ""; if (!base) base = p.base ?? ""; if (!ref) throw new Error(`${owner}/${repo}#${number} names no head commit`); } if (!ref) throw new Error("name a pull request's number or a commit"); const combined = await gitea.combinedStatus(owner, repo, ref); const statuses = newestByContext(combined.statuses); base = base || "main"; let rule: BranchProtection | null = null, unreadable: string | undefined; try { rule = await gitea.branchProtection(owner, repo, base); } catch (err) { unreadable = err instanceof Error ? err.message : String(err); } return { commit: combined.sha || ref, pull, combined: combined.state, statuses, merge: mergeableUnder(base, rule, statuses, unreadable) }; }, }, { name: "gitea_open_pulls", description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438). Reads only.", input: {}, run: async () => ({ pulls: await gitea.openPulls() }), }, { name: "gitea_contains", description: "Whether a commit is contained in another — an ancestor of it, or the same — from the forge's compare of the two: what mesh-delivery reads before it says a held merge is covered by a later one (novox/hq ADR 0270). Commits only, by sha or the start of one. Reads only.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, commit: { type: "string", description: "the commit asked about" }, in: { type: "string", description: "the commit it may be contained in" }, }, run: async (args) => { const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim(); const commit = String(args.commit ?? "").trim(), within = String(args.in ?? "").trim(); if (!owner || !repo) throw new Error("name the repository: owner and repo"); if (!isCommit(commit) || !isCommit(within)) throw new Error("commit and in are commits: a sha, or the start of one"); const { contains, ahead } = containedFrom(await gitea.compare(owner, repo, within, commit)); return { commit, in: within, contains, ahead }; }, }, { name: "gitea_commit_status", description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. The merge check's statuses (mesh/merge-gate, mesh/repo-check) take pending only — a recheck putting them back until the fresh verdict sets them.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, sha: { type: "string", description: "the commit" }, context: { type: "string", description: "the status's name, mesh/…" }, state: { type: "string", description: "pending | success | error | failure | warning" }, description: { type: "string", description: "one line" }, target_url: { type: "string", description: "the page it links to (the pull request)" }, }, run: async (args) => { const status = deliveryStatus(String(args.context), String(args.state), String(args.description ?? ""), args.target_url ? String(args.target_url) : undefined); await gitea.setCommitStatus(String(args.owner), String(args.repo), String(args.sha), status); return { set: status }; }, }, // ---- Labels ---- { name: "gitea_list_labels", description: "List every label defined in a repository.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, }, run: async (args) => ({ labels: await gitea.listLabels(String(args.owner), String(args.repo)) }), }, { name: "gitea_create_label", description: "Create a label in a repository.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, name: { type: "string", description: "the label name" }, color: { type: "string", description: "hex colour, e.g. #0075ca" }, description: { type: "string", description: "an optional description" }, }, run: async (args) => ({ label: await gitea.createLabel(String(args.owner), String(args.repo), { name: String(args.name), color: String(args.color), description: args.description ? String(args.description) : undefined, }), }), }, // ---- Escape hatch ---- { name: "gitea_api", description: "Make an authenticated Gitea API call for an endpoint without a dedicated tool. Path is relative to /api/v1. " + "Reads anything but the forge's administration; writes only what an author writes — issues, comments, labels, " + "pull requests (not their merge: gitea_merge_pull_request), branches, and files on a branch that is not the " + "default branch and that no protection rule names. Administration, protection, settings, collaborators, teams, " + "hooks, keys, tokens and statuses are refused (novox/hq ADR 0266).", input: { path: { type: "string", description: "API path relative to /api/v1, e.g. /repos/owner/repo/branches" }, method: { type: "string", description: "GET | POST | PUT | PATCH | DELETE (default GET)" }, body: { type: "object", description: "JSON request body for POST/PUT/PATCH" }, }, run: async (args) => { const method = (args.method ? String(args.method) : "GET").toUpperCase(); const judged = judgeApiCall(method, String(args.path ?? ""), args.body); if ("refused" in judged) throw new Refused(judged.refused); if ("contents" in judged) { const { owner, repo, branch, newBranch } = judged.contents; const found = await gitea.api<{ default_branch?: string }>(`/repos/${checkName("owner", owner)}/${checkName("name", repo)}`); const defaultBranch = String(found?.default_branch ?? "main"); const refused = judgeContentsWrite(newBranch ?? branch ?? defaultBranch, defaultBranch, await gitea.branchProtections(owner, repo)); if (refused) throw new Refused(refused); } // The path judged is the path sent: normalised, with only the allow-listed query keys (guard.ts). const raw = String(args.path ?? ""); const result = await gitea.api(routedPath(raw) + sentQuery(raw), { method, ...(args.body ? { body: JSON.stringify(args.body) } : {}), }); return { result }; }, }, ]; } // The tools exist when the client has a way to a token: one configured, or the admin account to mint // one with (token.ts). The mint itself happens on the first call, not here — a contributor is // synchronous, and a forge not yet answering must not keep the runtime from serving. Without either // way, gitea contributes none rather than failing the whole runtime, and says why. registerModuleTools("gitea", (env) => { try { return getGiteaTools(GiteaClient.fromEnv(env)); } catch (err) { console.log(`[gitea] no tools — ${err instanceof Error ? err.message : String(err)}`); return []; } });