// 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. // // Two tools emit an event at the natural point of the action they take (novox/hq ADR 0041/0042): // create-issue emits issue.opened, merge-pull-request emits pull.merged — the mesh's own hand on // the forge, announced the instant it moves. repo.created is deliberately NOT emitted here: repos // are far more often born from a `git push` or the web UI than from this tool, so the events // entrypoint (index.ts) owns that one by polling, which catches every path without this tool and // the poll double-announcing the same repo from two processes. import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { emit } from "@novox/mesh-sdk/events"; import { GiteaClient } from "../client.js"; /** 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("module.gitea.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); // Read the PR first, so the merged event carries a title and branches, not just a number. const pull = await gitea.getPullRequest(owner, repo, number); await gitea.mergePullRequest(owner, repo, number, method, deleteBranch); await emit("module.gitea.pull.merged", { owner, repo, number, title: pull.title, head: pull.head, base: pull.base, method, html_url: pull.html_url, }); return { merged: true, number, method, deleted_branch: deleteBranch }; }, }, // ---- 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 only when a token can be found; without one, gitea contributes none rather than // failing the whole runtime. registerModuleTools("gitea", (env) => { try { return getGiteaTools(GiteaClient.fromEnv(env)); } catch { return []; } });