gitea: full nox module — client, tools and events (ADR 0044/0046)

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.
This commit is contained in:
2026-09-04 02:30:40 +02:00
parent 4d98da18a0
commit 259c3721b5
6 changed files with 639 additions and 1 deletions
+241
View File
@@ -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<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `token ${this.token}`,
...(options.headers as Record<string, string> | 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<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
return this.request<T>(path, options);
}
// ---- Repositories ----
async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> {
const repos = await this.request<any[]>(`/user/repos?page=${page}&limit=${limit}`);
return (repos ?? []).map(GiteaClient.mapRepo);
}
async createRepo(data: {
name: string;
description?: string;
private?: boolean;
auto_init?: boolean;
}): Promise<GiteaRepo> {
return GiteaClient.mapRepo(await this.request<any>("/user/repos", { method: "POST", body: JSON.stringify(data) }));
}
async deleteRepo(owner: string, repo: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}`, { method: "DELETE" });
}
// ---- Issues ----
async listIssues(owner: string, repo: string, params: Record<string, string> = {}): Promise<GiteaIssue[]> {
const qs = new URLSearchParams({ type: "issues", ...params }).toString();
const issues = await this.request<any[]>(`/repos/${owner}/${repo}/issues?${qs}`);
return (issues ?? []).map(GiteaClient.mapIssue);
}
async getIssue(owner: string, repo: string, index: number): Promise<GiteaIssue> {
return GiteaClient.mapIssue(await this.request<any>(`/repos/${owner}/${repo}/issues/${index}`));
}
async createIssue(
owner: string,
repo: string,
data: { title: string; body?: string; labels?: number[] },
): Promise<GiteaIssue> {
return GiteaClient.mapIssue(
await this.request<any>(`/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<GiteaIssue> {
return GiteaClient.mapIssue(
await this.request<any>(`/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<any>(`/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<GiteaLabel[]> {
const labels = await this.request<any[]>(`/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<GiteaLabel> {
const l = await this.request<any>(`/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<number> {
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<string, string> = {}): Promise<GiteaPull[]> {
const qs = new URLSearchParams(params).toString();
const prs = await this.request<any[]>(`/repos/${owner}/${repo}/pulls?${qs}`);
return (prs ?? []).map(GiteaClient.mapPull);
}
async getPullRequest(owner: string, repo: string, index: number): Promise<GiteaPull> {
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
}
async createPullRequest(
owner: string,
repo: string,
data: { title: string; body?: string; head: string; base: string },
): Promise<GiteaPull> {
return GiteaClient.mapPull(
await this.request<any>(`/repos/${owner}/${repo}/pulls`, { method: "POST", body: JSON.stringify(data) }),
);
}
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise<void> {
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,
};
}
}
+59
View File
@@ -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<string>();
let primed = false;
async function pollRepos(client: GiteaClient): Promise<void> {
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<void>, 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");
}
+7 -1
View File
@@ -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": [
{
+14
View File
@@ -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"
}
}
+306
View File
@@ -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<string, string> = {
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 [];
}
});
+12
View File
@@ -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"]
}