mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
The recheck's pending was written from the tools' bundle, its own process, past the writer's per-head chain: the first run's verdict mark stayed set, so a rechecked head never said where its new run builds, and an old run's verdict in flight or redelivered could land over the pending. The status tool is now served from the watcher's bundle and writes the merge check's pending through the writer, and a verdict whose ask (checked.id) predates the check now pending is dropped, weighed after a restart against the forge's own history of the gate (novox/hq issue 456).
723 lines
40 KiB
TypeScript
723 lines
40 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, 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));
|
|
}
|
|
|
|
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_merge_pull_request",
|
|
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head " +
|
|
"and every status its base branch's protection requires succeeded there (mesh/merge-gate, mesh/repo-check), " +
|
|
"optionally deleting the source branch afterwards. Refused otherwise, with nothing merged: no tool merges past " +
|
|
"a check, and none merges a head that moved after it was reviewed.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
number: { type: "number", description: "the PR number" },
|
|
head: { type: "string", description: "the commit you reviewed, as its sha or at least its first 12 characters: " +
|
|
"refused unless it is the pull request's head" },
|
|
method: { type: "string", description: "merge (default) | fast-forward-only | squash | rebase | rebase-merge. " +
|
|
"Only merge and fast-forward-only can be read back to prove the head you named is what landed" },
|
|
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";
|
|
if (!MERGE_METHODS.has(method)) throw new Refused(`${method} is not a merge method: ${[...MERGE_METHODS].join(", ")}`);
|
|
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
|
|
// **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here rather than left
|
|
// to the forge, whose administrator — the account this tool's token is minted for — may merge past a
|
|
// rule that lets it. A rule that cannot be read is no rule passed.
|
|
// **Only the head that was reviewed** (novox/hq issue 381): mesh-controller#197 was reviewed at 065cfa0d,
|
|
// force-pushed to 3ced96f, checked green there, and merged at 3ced96f, because this tool merged whatever the
|
|
// head was and nothing told it which head the review had approved. The caller names it; a head that moved
|
|
// since is refused, and the forge is handed the same head, so a push between this read and the merge is
|
|
// refused by the forge too.
|
|
const named = args.head === undefined ? "" : String(args.head).trim().toLowerCase();
|
|
if (!named) {
|
|
throw new Refused(`${owner}/${repo}#${number} is not merged: name the head you reviewed (head), so that only ` +
|
|
`that commit can land (novox/hq issue 381). Nothing was merged`);
|
|
}
|
|
// Twelve characters, not seven: an author who force-pushes can make a commit whose first seven match the
|
|
// reviewed one in seconds, and the review would then merge it. Twelve cannot be matched that way.
|
|
if (!REVIEWED_HEAD.test(named)) {
|
|
throw new Refused(`${owner}/${repo}#${number} is not merged: head ${named} does not name one commit — give its ` +
|
|
`sha, or at least its first 12 characters. Nothing was merged`);
|
|
}
|
|
const pull = await gitea.getPullRequest(owner, repo, number);
|
|
if (pull.merged) throw new Refused(`${owner}/${repo}#${number} is already merged`);
|
|
if (!pull.head_sha || !pull.base) throw new Refused(`${owner}/${repo}#${number} names no head commit or base branch`);
|
|
if (!pull.head_sha.toLowerCase().startsWith(named)) {
|
|
throw new Refused(`${owner}/${repo}#${number} is not merged: its head is ${pull.head_sha.slice(0, 12)}, not the ` +
|
|
`${named} you named. If you reviewed an earlier head, review the one it has now, then name it. Nothing was merged`);
|
|
}
|
|
const rule = await gitea.branchProtection(owner, repo, pull.base);
|
|
// Only into a branch whose protection requires the checks: a pull request whose base was moved to a
|
|
// branch nothing guards would merge past every check (novox/hq ADR 0266).
|
|
if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) {
|
|
throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule requiring ` +
|
|
`the checks guards: a tool merges only into such a branch (novox/hq ADR 0266). Nothing was merged`);
|
|
}
|
|
const statuses = newestByContext((await gitea.combinedStatus(owner, repo, pull.head_sha)).statuses);
|
|
const verdict = mergeableUnder(pull.base, rule, statuses);
|
|
if (verdict.mergeable !== true) {
|
|
throw new Refused(`${owner}/${repo}#${number} is not merged: ${verdict.says}. A pull request merges once ` +
|
|
`its checks succeed; no tool merges past them (novox/hq ADR 0266)`);
|
|
}
|
|
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch, pull.head_sha);
|
|
// 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.
|
|
// From here the merge has happened. Nothing below may read as if it had not: a read-back that fails or cannot
|
|
// be done is said beside merged: true, and only a proven other head is an error — one that says the merge happened.
|
|
const answer: Record<string, unknown> = { merged: true, number, method, head: pull.head_sha, deleted_branch: deleteBranch };
|
|
let landed: string | undefined;
|
|
try {
|
|
const merged = await gitea.getPullRequest(owner, repo, number);
|
|
answer.merge_commit_sha = merged.merge_commit_sha;
|
|
// What landed, read back from the commit the merge made rather than from the forge's record of the pull
|
|
// request, which is the very thing a race would leave behind. A merge commit's second parent is the head it
|
|
// joined; a fast-forward lands the head itself. A squash or a rebase makes new commits that name no head.
|
|
if (!merged.merge_commit_sha) {
|
|
answer.read_back = "the forge named no merge commit, so what landed cannot be read back";
|
|
} else if (method === "merge") {
|
|
const parents = await gitea.commitParents(owner, repo, merged.merge_commit_sha);
|
|
if (parents.length === 2) landed = parents[1];
|
|
else answer.read_back = `the merge commit has ${parents.length} parent(s), not two: what landed cannot be read back`;
|
|
} else if (method === "fast-forward-only") {
|
|
// That the forge names the head itself as a fast-forward's merge commit is assumed, not seen on this forge:
|
|
// a different sha is said, not raised as another head having landed.
|
|
if (merged.merge_commit_sha === pull.head_sha) landed = merged.merge_commit_sha;
|
|
else answer.read_back = `the forge named ${merged.merge_commit_sha.slice(0, 12)} as this fast-forward's commit, ` +
|
|
`not the head: that the head landed could not be confirmed`;
|
|
} else {
|
|
answer.read_back = `a ${method} makes new commits that name no head: what landed cannot be read back`;
|
|
}
|
|
} catch (err) {
|
|
answer.read_back = `the merge happened, but reading back what landed failed: ${(err as Error).message}`;
|
|
}
|
|
// Loud, not a field beside merged: true that a caller reading only that would miss. The merge cannot be taken
|
|
// back by this tool; what must not happen is that it reads as the merge that was asked for.
|
|
if (landed !== undefined && landed !== pull.head_sha) {
|
|
throw new Error(`${owner}/${repo}#${number} WAS merged (${answer.merge_commit_sha}), but the forge merged ` +
|
|
`${landed.slice(0, 12)}, not the ${pull.head_sha.slice(0, 12)} it was handed and you reviewed: a commit nobody ` +
|
|
`reviewed has landed. Read it before anything is built from it (novox/hq issue 381)`);
|
|
}
|
|
if (landed !== undefined) answer.landed = landed;
|
|
return answer;
|
|
},
|
|
},
|
|
|
|
{
|
|
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.branchProtection(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). Reads only.",
|
|
input: {},
|
|
run: async () => ({ pulls: await gitea.openPulls() }),
|
|
},
|
|
{
|
|
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");
|
|
const refused = judgeContentsWrite(newBranch ?? branch ?? defaultBranch, defaultBranch,
|
|
await gitea.branchProtections(owner, repo));
|
|
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 [];
|
|
}
|
|
});
|