mesh/delivery-group group feat/delivery-checks-verb delivering: 0 of 2 delivered
mesh/merge-gate pass: builds gitea, mesh-delivery → 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
mesh/delivery superseded: a newer delivery to the same trunk took over its walk
Reading a pull request's mesh/merge-gate and mesh/repo-check meant scraping the controller's journal: the forge clips each status to 140 characters and nothing returned the verdict whole. mesh-delivery already keeps the verdict; it now keeps the machine that ran it, the layers' modules and the report, and its checks verb joins that to the forge's statuses, read through a new gitea_commit_statuses tool that also says whether the base's protection lets the commit merge. after: novox/mesh-controller
577 lines
28 KiB
TypeScript
577 lines
28 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, deliveryStatus, run, viewBody, viewComment } from "../delivery.js";
|
|
import { mergeableUnder, newestByContext } from "../checks.js";
|
|
|
|
/** 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);
|
|
}
|
|
|
|
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("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);
|
|
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
|
|
// 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.
|
|
const merged = await gitea.getPullRequest(owner, repo, number);
|
|
return { merged: true, number, method, deleted_branch: deleteBranch, merge_commit_sha: merged.merge_commit_sha };
|
|
},
|
|
},
|
|
|
|
{
|
|
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: "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");
|
|
const before = await gitea.branchProtection(owner, repo, branch);
|
|
const { created, rule } = await gitea.setBranchProtection(owner, repo, branch, {
|
|
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),
|
|
});
|
|
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_commit_status",
|
|
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
sha: { type: "string", description: "the commit" },
|
|
context: { type: "string", description: "the status's name, mesh/…" },
|
|
state: { type: "string", description: "pending | success | error | failure | warning" },
|
|
description: { type: "string", description: "one line" },
|
|
target_url: { type: "string", description: "the page it links to (the pull request)" },
|
|
},
|
|
run: async (args) => {
|
|
const status = deliveryStatus(String(args.context), String(args.state), String(args.description ?? ""),
|
|
args.target_url ? String(args.target_url) : undefined);
|
|
await gitea.setCommitStatus(String(args.owner), String(args.repo), String(args.sha), status);
|
|
return { set: status };
|
|
},
|
|
},
|
|
|
|
// ---- 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 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 [];
|
|
}
|
|
});
|