// 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"; /** 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); } export function getGiteaTools(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. Destructive and irreversible — requires confirm=true.", input: { owner: { type: "string", description: "the repository owner" }, name: { type: "string", description: "the repository name" }, confirm: { type: "boolean", description: "must be true to actually delete" }, }, run: async (args) => { if (!args.confirm) return { deleted: false, reason: "confirm must be true to delete a repository" }; 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, optionally deleting the source branch afterwards.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, number: { type: "number", description: "the PR number" }, method: { type: "string", description: "merge | rebase | squash (default merge)" }, 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"; const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch); await gitea.mergePullRequest(owner, repo, number, method, deleteBranch); // 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. const merged = await gitea.getPullRequest(owner, repo, number); return { merged: true, number, method, deleted_branch: deleteBranch, merge_commit_sha: merged.merge_commit_sha }; }, }, { 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: "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"); const before = await gitea.branchProtection(owner, repo, branch); const { created, rule } = await gitea.setBranchProtection(owner, repo, branch, { 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), }); 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_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.", 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 any endpoint without a dedicated tool. Path is relative to /api/v1.", 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"; const result = await gitea.api(String(args.path), { 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 []; } });