The merge tool announced pull.merged and so did the poll added for issue 131, so every merge made through the tool reached the controller twice. The poll sees every path and carries the clone url; it is now the only emitter. hq issue 250.
426 lines
19 KiB
TypeScript
426 lines
19 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 } from "../client.js";
|
|
|
|
/** Coerce a comma-separated label string into names; empty/absent yields none. */
|
|
function parseLabels(raw: unknown): string[] {
|
|
if (raw === undefined || raw === null || raw === "") return [];
|
|
return String(raw)
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|
return [
|
|
// ---- Repositories ----
|
|
{
|
|
name: "gitea_list_repos",
|
|
description: "List repositories for the authenticated Gitea user.",
|
|
input: {
|
|
page: { type: "number", description: "page number (default 1)" },
|
|
limit: { type: "number", description: "how many per page (default 20)" },
|
|
},
|
|
run: async (args) => ({
|
|
repos: await gitea.listRepos(args.page ? Number(args.page) : 1, args.limit ? Number(args.limit) : 20),
|
|
}),
|
|
},
|
|
{
|
|
name: "gitea_create_repo",
|
|
description: "Create a repository owned by the authenticated user.",
|
|
input: {
|
|
name: { type: "string", description: "the repository name" },
|
|
description: { type: "string", description: "an optional description" },
|
|
private: { type: "boolean", description: "private repo (default true)" },
|
|
auto_init: { type: "boolean", description: "initialise with a README (default true)" },
|
|
},
|
|
run: async (args) => {
|
|
const repo = await gitea.createRepo({
|
|
name: String(args.name),
|
|
description: args.description ? String(args.description) : undefined,
|
|
private: args.private === undefined ? true : Boolean(args.private),
|
|
auto_init: args.auto_init === undefined ? true : Boolean(args.auto_init),
|
|
});
|
|
return { repo };
|
|
},
|
|
},
|
|
{
|
|
name: "gitea_delete_repo",
|
|
description: "Delete a repository. Destructive and irreversible — requires confirm=true.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
name: { type: "string", description: "the repository name" },
|
|
confirm: { type: "boolean", description: "must be true to actually delete" },
|
|
},
|
|
run: async (args) => {
|
|
if (!args.confirm) return { deleted: false, reason: "confirm must be true to delete a repository" };
|
|
await gitea.deleteRepo(String(args.owner), String(args.name));
|
|
return { deleted: true, repo: `${String(args.owner)}/${String(args.name)}` };
|
|
},
|
|
},
|
|
|
|
// ---- Issues ----
|
|
{
|
|
name: "gitea_list_issues",
|
|
description: "List issues for a repository, filterable by state and labels.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
state: { type: "string", description: "open | closed | all (default open)" },
|
|
labels: { type: "string", description: "comma-separated label names to filter by" },
|
|
page: { type: "number", description: "page number (default 1)" },
|
|
},
|
|
run: async (args) => {
|
|
const params: Record<string, string> = {
|
|
state: args.state ? String(args.state) : "open",
|
|
page: String(args.page ? Number(args.page) : 1),
|
|
};
|
|
if (args.labels) params.labels = String(args.labels);
|
|
return { issues: await gitea.listIssues(String(args.owner), String(args.repo), params) };
|
|
},
|
|
},
|
|
{
|
|
name: "gitea_get_issue",
|
|
description: "Get a single issue by its number.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
number: { type: "number", description: "the issue number" },
|
|
},
|
|
run: async (args) => ({
|
|
issue: await gitea.getIssue(String(args.owner), String(args.repo), Number(args.number)),
|
|
}),
|
|
},
|
|
{
|
|
name: "gitea_create_issue",
|
|
description: "Open a new issue. Label names are resolved to ids, creating any that are missing.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
title: { type: "string", description: "the issue title" },
|
|
body: { type: "string", description: "the issue body (markdown)" },
|
|
labels: { type: "string", description: "comma-separated label names" },
|
|
},
|
|
run: async (args) => {
|
|
const owner = String(args.owner);
|
|
const repo = String(args.repo);
|
|
const names = parseLabels(args.labels);
|
|
const labelIds = names.length
|
|
? await Promise.all(names.map((n) => gitea.getOrCreateLabel(owner, repo, n)))
|
|
: undefined;
|
|
const issue = await gitea.createIssue(owner, repo, {
|
|
title: String(args.title),
|
|
body: args.body ? String(args.body) : undefined,
|
|
labels: labelIds,
|
|
});
|
|
// The mesh just opened an issue — announce it the moment it exists.
|
|
await emit("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) };
|
|
},
|
|
},
|
|
|
|
// ---- 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 [];
|
|
}
|
|
});
|