From 259c3721b53d7ae92b1a056e91ec7ff3daa2e5da Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:30:40 +0200 Subject: [PATCH] =?UTF-8?q?gitea:=20full=20nox=20module=20=E2=80=94=20clie?= =?UTF-8?q?nt,=20tools=20and=20events=20(ADR=200044/0046)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git hosting. 15 tools (repos, issues, PRs, labels, api passthrough) moved out of the shared sdk. Emits repo.created (from a light poll, catching repos born of git push or the web UI), issue.opened and pull.merged (from the tools at the moment of the action) — the poll owns repo.created alone so it is never announced twice. Typechecks; manifest parses. --- modules/gitea/client.ts | 241 +++++++++++++++++++++++++++ modules/gitea/index.ts | 59 +++++++ modules/gitea/module.json | 8 +- modules/gitea/package.json | 14 ++ modules/gitea/tools/index.ts | 306 +++++++++++++++++++++++++++++++++++ modules/gitea/tsconfig.json | 12 ++ 6 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 modules/gitea/client.ts create mode 100644 modules/gitea/index.ts create mode 100644 modules/gitea/package.json create mode 100644 modules/gitea/tools/index.ts create mode 100644 modules/gitea/tsconfig.json diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts new file mode 100644 index 0000000..c17b84a --- /dev/null +++ b/modules/gitea/client.ts @@ -0,0 +1,241 @@ +// The Gitea API client — gitea's own code, living in the module (novox/hq ADR 0044). Moved out of +// the shared hal sdk, where a change to Gitea's API rebuilt everything; here it rebuilds only +// gitea. Both this module's tools and its events entrypoint import it, and nothing outside gitea +// does. + +/** A repository, trimmed to what the mesh cares about. */ +export interface GiteaRepo { + full_name: string; + name: string; + owner: string; + private: boolean; + description?: string; + html_url: string; + default_branch?: string; +} + +/** An issue, with its labels flattened to names. */ +export interface GiteaIssue { + number: number; + title: string; + state: string; + user?: string; + labels: string[]; + html_url: string; + body?: string; +} + +/** A pull request, trimmed to the fields a reviewer or an event body needs. */ +export interface GiteaPull { + number: number; + title: string; + state: string; + merged: boolean; + user?: string; + head?: string; + base?: string; + html_url: string; +} + +export interface GiteaLabel { + id: number; + name: string; +} + +export class GiteaClient { + readonly baseUrl: string; + private cachedUsername: string | null = null; + + constructor( + url: string, + private readonly token: string, + ) { + this.baseUrl = url.replace(/\/+$/, ""); + } + + /** + * Build from the module's resolved environment. URL and token come from MESH_GITEA_URL / + * MESH_GITEA_TOKEN (the mesh's own names), falling back to the bare GITEA_* names and, for the + * URL, to the forge's loopback port. A token is required — without one there is no authenticated + * call to make, so this throws rather than hand back a client that fails on first use. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient { + const url = env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; + const token = env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; + if (!token) throw new Error("no Gitea token — set MESH_GITEA_TOKEN"); + return new GiteaClient(url, token); + } + + private async request(path: string, options: RequestInit = {}): Promise { + const res = await fetch(`${this.baseUrl}/api/v1${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: `token ${this.token}`, + ...(options.headers as Record | undefined), + }, + }); + if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`); + if (res.status === 204) return null as T; + const text = await res.text(); + return (text ? JSON.parse(text) : null) as T; + } + + /** Generic authenticated API call — the escape hatch for endpoints without a dedicated method. + * Path is relative to /api/v1. */ + async api(path: string, options: RequestInit = {}): Promise { + return this.request(path, options); + } + + // ---- Repositories ---- + + async listRepos(page = 1, limit = 20): Promise { + const repos = await this.request(`/user/repos?page=${page}&limit=${limit}`); + return (repos ?? []).map(GiteaClient.mapRepo); + } + + async createRepo(data: { + name: string; + description?: string; + private?: boolean; + auto_init?: boolean; + }): Promise { + return GiteaClient.mapRepo(await this.request("/user/repos", { method: "POST", body: JSON.stringify(data) })); + } + + async deleteRepo(owner: string, repo: string): Promise { + await this.request(`/repos/${owner}/${repo}`, { method: "DELETE" }); + } + + // ---- Issues ---- + + async listIssues(owner: string, repo: string, params: Record = {}): Promise { + const qs = new URLSearchParams({ type: "issues", ...params }).toString(); + const issues = await this.request(`/repos/${owner}/${repo}/issues?${qs}`); + return (issues ?? []).map(GiteaClient.mapIssue); + } + + async getIssue(owner: string, repo: string, index: number): Promise { + return GiteaClient.mapIssue(await this.request(`/repos/${owner}/${repo}/issues/${index}`)); + } + + async createIssue( + owner: string, + repo: string, + data: { title: string; body?: string; labels?: number[] }, + ): Promise { + return GiteaClient.mapIssue( + await this.request(`/repos/${owner}/${repo}/issues`, { method: "POST", body: JSON.stringify(data) }), + ); + } + + /** Patch an issue's state — the one edit the close tool needs. */ + async setIssueState(owner: string, repo: string, index: number, state: "open" | "closed"): Promise { + return GiteaClient.mapIssue( + await this.request(`/repos/${owner}/${repo}/issues/${index}`, { + method: "PATCH", + body: JSON.stringify({ state }), + }), + ); + } + + async addComment(owner: string, repo: string, index: number, body: string): Promise<{ id: number; html_url: string }> { + const c = await this.request(`/repos/${owner}/${repo}/issues/${index}/comments`, { + method: "POST", + body: JSON.stringify({ body }), + }); + return { id: c.id, html_url: c.html_url }; + } + + // ---- Labels ---- + + async listLabels(owner: string, repo: string): Promise { + const labels = await this.request(`/repos/${owner}/${repo}/labels`); + return (labels ?? []).map((l: any) => ({ id: l.id, name: l.name })); + } + + async createLabel( + owner: string, + repo: string, + data: { name: string; color: string; description?: string }, + ): Promise { + const l = await this.request(`/repos/${owner}/${repo}/labels`, { method: "POST", body: JSON.stringify(data) }); + return { id: l.id, name: l.name }; + } + + /** Resolve a label name to its id, creating it if it does not exist — so create-issue can take + * human label names and not numeric ids. */ + async getOrCreateLabel(owner: string, repo: string, name: string, color = "#0075ca"): Promise { + const existing = (await this.listLabels(owner, repo)).find((l) => l.name === name); + if (existing) return existing.id; + return (await this.createLabel(owner, repo, { name, color })).id; + } + + // ---- Pull requests ---- + + async listPullRequests(owner: string, repo: string, params: Record = {}): Promise { + const qs = new URLSearchParams(params).toString(); + const prs = await this.request(`/repos/${owner}/${repo}/pulls?${qs}`); + return (prs ?? []).map(GiteaClient.mapPull); + } + + async getPullRequest(owner: string, repo: string, index: number): Promise { + return GiteaClient.mapPull(await this.request(`/repos/${owner}/${repo}/pulls/${index}`)); + } + + async createPullRequest( + owner: string, + repo: string, + data: { title: string; body?: string; head: string; base: string }, + ): Promise { + return GiteaClient.mapPull( + await this.request(`/repos/${owner}/${repo}/pulls`, { method: "POST", body: JSON.stringify(data) }), + ); + } + + async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise { + await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, { + method: "POST", + body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch }), + }); + } + + // ---- Mappers: the wire shape is broad and unstable; the mesh sees only these fields. ---- + + private static mapRepo(r: any): GiteaRepo { + return { + full_name: r.full_name, + name: r.name, + owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown", + private: Boolean(r.private), + description: r.description || undefined, + html_url: r.html_url, + default_branch: r.default_branch, + }; + } + + private static mapIssue(i: any): GiteaIssue { + return { + number: i.number, + title: i.title, + state: i.state, + user: i.user?.login, + labels: (i.labels ?? []).map((l: any) => l.name), + html_url: i.html_url, + body: i.body || undefined, + }; + } + + private static mapPull(p: any): GiteaPull { + return { + number: p.number, + title: p.title, + state: p.state, + merged: Boolean(p.merged), + user: p.user?.login, + head: p.head?.ref, + base: p.base?.ref, + html_url: p.html_url, + }; + } +} diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts new file mode 100644 index 0000000..15366b1 --- /dev/null +++ b/modules/gitea/index.ts @@ -0,0 +1,59 @@ +// gitea's events. The tool runtime imports this once the broker is bound. It watches the forge and +// emits what appeared. +// +// Emits (novox/hq ADR 0046/0047): +// module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool) +// +// issue.opened and pull.merged are emitted from the tools (tools/index.ts), at the instant the mesh +// takes that action — the natural point, and one process only. repo.created belongs here instead: +// a repository is usually born from a `git push` or the web UI, which no tool sees, so polling the +// repo list is the only way to catch every path — and keeping it out of the create-repo tool means +// the fact is never announced twice from two processes. +// +// The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering +// the forge for an immediacy nobody asked for is not. + +import { emit } from "@novox/mesh-sdk/events"; +import { GiteaClient } from "./client.js"; + +// Without a token there is nothing to watch; log and stay quiet rather than crash the runtime. +let gitea: GiteaClient | null = null; +try { + gitea = GiteaClient.fromEnv(); +} catch (err) { + console.log(`[gitea] not watching — ${err instanceof Error ? err.message : String(err)}`); +} + +// New repositories, by diffing the repo list. Primed silently on the first look, or a restart would +// re-announce every existing repository as freshly created. +const seen = new Set(); +let primed = false; +async function pollRepos(client: GiteaClient): Promise { + const repos = await client.listRepos(1, 50); + for (const repo of repos) { + if (!seen.has(repo.full_name)) { + if (primed) { + await emit("module.gitea.repo.created", { + full_name: repo.full_name, + owner: repo.owner, + name: repo.name, + private: repo.private, + html_url: repo.html_url, + }); + } + seen.add(repo.full_name); + } + } + primed = true; +} + +if (gitea) { + const client = gitea; + const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[gitea] ${err}`)); + setInterval(run, everyMs); + run(); + }; + tick(() => pollRepos(client), 60_000); + console.log("[gitea] watching for new repositories"); +} diff --git a/modules/gitea/module.json b/modules/gitea/module.json index feb28b8..83133c8 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -18,6 +18,11 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.gitea.repo.created", + "module.gitea.issue.opened", + "module.gitea.pull.merged" + ], "listens": [ { "port": 3000, @@ -33,7 +38,8 @@ } ], "own-secrets": { - "internal-token": "/var/lib/gitea/internal-token.secret" + "internal-token": "/var/lib/gitea/internal-token.secret", + "broker": "/var/lib/gitea/broker" }, "resources": [ { diff --git a/modules/gitea/package.json b/modules/gitea/package.json new file mode 100644 index 0000000..fe30cf5 --- /dev/null +++ b/modules/gitea/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-gitea", + "version": "0.1.0", + "description": "gitea — git hosting. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts new file mode 100644 index 0000000..d970d9f --- /dev/null +++ b/modules/gitea/tools/index.ts @@ -0,0 +1,306 @@ +// gitea's tools — moved here from the shared sdk (novox/hq ADR 0044), 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 0046/0047): +// 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 []; + } +}); diff --git a/modules/gitea/tsconfig.json b/modules/gitea/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/gitea/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +}