Files
mesh-catalog/modules/gitea/tools/index.ts
T
mesh-adminandClaude Opus 5.5 48f0662fde
mesh/merge-gate head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Make the forge's tools act as an account of their own, not the administrator (issue 469, ADR 0365 clause 2)
The tools' token reached everything the administrator may do, so their boundary rested on their own guards.
The gitea module now makes a plain account, mesh-tools, with the admin login, gives it write on code, issues
and pull requests of every novox repository through one team, and mints the tools' token for it; the admin's
old token goes only after the new one is kept. The Go forge bundle sends that token, never the admin login.
The forge shows protection rules only to a repository administrator, so every check reads a branch's
protection from the branch. mesh-admin stays a ready author, so marks made before the move keep counting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:28:25 +00:00

624 lines
32 KiB
TypeScript

// 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, readyAuthors, type BranchProtection } from "../client.js";
import { appendNote, 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<string, unknown>) => {
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));
}
// gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one
// writer (novox/hq issue 456, ADR 0323).
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<Array<{ created?: string; commit?: { committer?: { date?: string } } }>>(
`/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<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("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_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/<ref> (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.appliedProtection(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), and each pull request's labels and ready mark — the label mesh/ready and the head named by the newest comment \"ready at <full head sha>\" that a listed account (the setting ready-authors) wrote after the label was added and after the last push — which mesh-delivery merges on (issue 722). Reads only.",
input: {},
run: async () => ({ pulls: await gitea.openPulls(readyAuthors()) }),
},
{
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 };
},
},
// gitea_commit_status is served from the watcher's bundle (index.ts), beside the merge check's one writer
// (novox/hq issue 456).
// ---- 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");
// The protection the forge applies to the branch written, read from the branch: the rules' own list is a
// repository administrator's, which the tools' account is not (novox/hq ADR 0365). A branch not made yet is
// left to the forge, which refuses a write that would make a branch a rule guards.
const target = newBranch ?? branch ?? defaultBranch;
const applied = target === defaultBranch ? null : await gitea.appliedProtection(owner, repo, target);
const refused = judgeContentsWrite(target, defaultBranch, applied ? [applied] : []);
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 [];
}
});