Git hosting. 15 tools (repos, issues, PRs, labels, api passthrough) moved out of the shared sdk. Emits repo.created (from a light poll, catching repos born of git push or the web UI), issue.opened and pull.merged (from the tools at the moment of the action) — the poll owns repo.created alone so it is never announced twice. Typechecks; manifest parses.
307 lines
13 KiB
TypeScript
307 lines
13 KiB
TypeScript
// gitea's tools — moved here from the shared sdk (novox/hq ADR 0044), importing gitea's own client.
|
|
// They return structured data; the mesh serves them through the sdk's tool harness.
|
|
//
|
|
// Two tools emit an event at the natural point of the action they take (novox/hq ADR 0046/0047):
|
|
// create-issue emits issue.opened, merge-pull-request emits pull.merged — the mesh's own hand on
|
|
// the forge, announced the instant it moves. repo.created is deliberately NOT emitted here: repos
|
|
// are far more often born from a `git push` or the web UI than from this tool, so the events
|
|
// entrypoint (index.ts) owns that one by polling, which catches every path without this tool and
|
|
// the poll double-announcing the same repo from two processes.
|
|
|
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
|
import { emit } from "@novox/mesh-sdk/events";
|
|
import { GiteaClient } from "../client.js";
|
|
|
|
/** Coerce a comma-separated label string into names; empty/absent yields none. */
|
|
function parseLabels(raw: unknown): string[] {
|
|
if (raw === undefined || raw === null || raw === "") return [];
|
|
return String(raw)
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|
return [
|
|
// ---- Repositories ----
|
|
{
|
|
name: "gitea_list_repos",
|
|
description: "List repositories for the authenticated Gitea user.",
|
|
input: {
|
|
page: { type: "number", description: "page number (default 1)" },
|
|
limit: { type: "number", description: "how many per page (default 20)" },
|
|
},
|
|
run: async (args) => ({
|
|
repos: await gitea.listRepos(args.page ? Number(args.page) : 1, args.limit ? Number(args.limit) : 20),
|
|
}),
|
|
},
|
|
{
|
|
name: "gitea_create_repo",
|
|
description: "Create a repository owned by the authenticated user.",
|
|
input: {
|
|
name: { type: "string", description: "the repository name" },
|
|
description: { type: "string", description: "an optional description" },
|
|
private: { type: "boolean", description: "private repo (default true)" },
|
|
auto_init: { type: "boolean", description: "initialise with a README (default true)" },
|
|
},
|
|
run: async (args) => {
|
|
const repo = await gitea.createRepo({
|
|
name: String(args.name),
|
|
description: args.description ? String(args.description) : undefined,
|
|
private: args.private === undefined ? true : Boolean(args.private),
|
|
auto_init: args.auto_init === undefined ? true : Boolean(args.auto_init),
|
|
});
|
|
return { repo };
|
|
},
|
|
},
|
|
{
|
|
name: "gitea_delete_repo",
|
|
description: "Delete a repository. Destructive and irreversible — requires confirm=true.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
name: { type: "string", description: "the repository name" },
|
|
confirm: { type: "boolean", description: "must be true to actually delete" },
|
|
},
|
|
run: async (args) => {
|
|
if (!args.confirm) return { deleted: false, reason: "confirm must be true to delete a repository" };
|
|
await gitea.deleteRepo(String(args.owner), String(args.name));
|
|
return { deleted: true, repo: `${String(args.owner)}/${String(args.name)}` };
|
|
},
|
|
},
|
|
|
|
// ---- Issues ----
|
|
{
|
|
name: "gitea_list_issues",
|
|
description: "List issues for a repository, filterable by state and labels.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
state: { type: "string", description: "open | closed | all (default open)" },
|
|
labels: { type: "string", description: "comma-separated label names to filter by" },
|
|
page: { type: "number", description: "page number (default 1)" },
|
|
},
|
|
run: async (args) => {
|
|
const params: Record<string, string> = {
|
|
state: args.state ? String(args.state) : "open",
|
|
page: String(args.page ? Number(args.page) : 1),
|
|
};
|
|
if (args.labels) params.labels = String(args.labels);
|
|
return { issues: await gitea.listIssues(String(args.owner), String(args.repo), params) };
|
|
},
|
|
},
|
|
{
|
|
name: "gitea_get_issue",
|
|
description: "Get a single issue by its number.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
number: { type: "number", description: "the issue number" },
|
|
},
|
|
run: async (args) => ({
|
|
issue: await gitea.getIssue(String(args.owner), String(args.repo), Number(args.number)),
|
|
}),
|
|
},
|
|
{
|
|
name: "gitea_create_issue",
|
|
description: "Open a new issue. Label names are resolved to ids, creating any that are missing.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
title: { type: "string", description: "the issue title" },
|
|
body: { type: "string", description: "the issue body (markdown)" },
|
|
labels: { type: "string", description: "comma-separated label names" },
|
|
},
|
|
run: async (args) => {
|
|
const owner = String(args.owner);
|
|
const repo = String(args.repo);
|
|
const names = parseLabels(args.labels);
|
|
const labelIds = names.length
|
|
? await Promise.all(names.map((n) => gitea.getOrCreateLabel(owner, repo, n)))
|
|
: undefined;
|
|
const issue = await gitea.createIssue(owner, repo, {
|
|
title: String(args.title),
|
|
body: args.body ? String(args.body) : undefined,
|
|
labels: labelIds,
|
|
});
|
|
// The mesh just opened an issue — announce it the moment it exists.
|
|
await emit("module.gitea.issue.opened", {
|
|
owner,
|
|
repo,
|
|
number: issue.number,
|
|
title: issue.title,
|
|
user: issue.user,
|
|
html_url: issue.html_url,
|
|
});
|
|
return { issue };
|
|
},
|
|
},
|
|
{
|
|
name: "gitea_close_issue",
|
|
description: "Close an open issue.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
number: { type: "number", description: "the issue number" },
|
|
},
|
|
run: async (args) => ({
|
|
issue: await gitea.setIssueState(String(args.owner), String(args.repo), Number(args.number), "closed"),
|
|
}),
|
|
},
|
|
{
|
|
name: "gitea_add_comment",
|
|
description: "Add a comment to an issue or pull request.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
number: { type: "number", description: "the issue or PR number" },
|
|
body: { type: "string", description: "the comment body (markdown)" },
|
|
},
|
|
run: async (args) => ({
|
|
comment: await gitea.addComment(String(args.owner), String(args.repo), Number(args.number), String(args.body)),
|
|
}),
|
|
},
|
|
|
|
// ---- Pull requests ----
|
|
{
|
|
name: "gitea_list_pull_requests",
|
|
description: "List pull requests for a repository.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
state: { type: "string", description: "open | closed | all (default open)" },
|
|
page: { type: "number", description: "page number (default 1)" },
|
|
limit: { type: "number", description: "how many per page (default 20)" },
|
|
},
|
|
run: async (args) => ({
|
|
pulls: await gitea.listPullRequests(String(args.owner), String(args.repo), {
|
|
state: args.state ? String(args.state) : "open",
|
|
page: String(args.page ? Number(args.page) : 1),
|
|
limit: String(args.limit ? Number(args.limit) : 20),
|
|
}),
|
|
}),
|
|
},
|
|
{
|
|
name: "gitea_get_pull_request",
|
|
description: "Get a single pull request by its number.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
number: { type: "number", description: "the PR number" },
|
|
},
|
|
run: async (args) => ({
|
|
pull: await gitea.getPullRequest(String(args.owner), String(args.repo), Number(args.number)),
|
|
}),
|
|
},
|
|
{
|
|
name: "gitea_create_pull_request",
|
|
description: "Open a pull request from a head branch into a base branch.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
title: { type: "string", description: "the PR title" },
|
|
body: { type: "string", description: "the PR body (markdown)" },
|
|
head: { type: "string", description: "the source branch" },
|
|
base: { type: "string", description: "the target branch (default main)" },
|
|
},
|
|
run: async (args) => ({
|
|
pull: await gitea.createPullRequest(String(args.owner), String(args.repo), {
|
|
title: String(args.title),
|
|
body: args.body ? String(args.body) : undefined,
|
|
head: String(args.head),
|
|
base: args.base ? String(args.base) : "main",
|
|
}),
|
|
}),
|
|
},
|
|
{
|
|
name: "gitea_merge_pull_request",
|
|
description: "Merge a pull request, optionally deleting the source branch afterwards.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
number: { type: "number", description: "the PR number" },
|
|
method: { type: "string", description: "merge | rebase | squash (default merge)" },
|
|
delete_branch: { type: "boolean", description: "delete the source branch after merge (default true)" },
|
|
},
|
|
run: async (args) => {
|
|
const owner = String(args.owner);
|
|
const repo = String(args.repo);
|
|
const number = Number(args.number);
|
|
const method = args.method ? String(args.method) : "merge";
|
|
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
|
|
// Read the PR first, so the merged event carries a title and branches, not just a number.
|
|
const pull = await gitea.getPullRequest(owner, repo, number);
|
|
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
|
|
await emit("module.gitea.pull.merged", {
|
|
owner,
|
|
repo,
|
|
number,
|
|
title: pull.title,
|
|
head: pull.head,
|
|
base: pull.base,
|
|
method,
|
|
html_url: pull.html_url,
|
|
});
|
|
return { merged: true, number, method, deleted_branch: deleteBranch };
|
|
},
|
|
},
|
|
|
|
// ---- Labels ----
|
|
{
|
|
name: "gitea_list_labels",
|
|
description: "List every label defined in a repository.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
},
|
|
run: async (args) => ({ labels: await gitea.listLabels(String(args.owner), String(args.repo)) }),
|
|
},
|
|
{
|
|
name: "gitea_create_label",
|
|
description: "Create a label in a repository.",
|
|
input: {
|
|
owner: { type: "string", description: "the repository owner" },
|
|
repo: { type: "string", description: "the repository name" },
|
|
name: { type: "string", description: "the label name" },
|
|
color: { type: "string", description: "hex colour, e.g. #0075ca" },
|
|
description: { type: "string", description: "an optional description" },
|
|
},
|
|
run: async (args) => ({
|
|
label: await gitea.createLabel(String(args.owner), String(args.repo), {
|
|
name: String(args.name),
|
|
color: String(args.color),
|
|
description: args.description ? String(args.description) : undefined,
|
|
}),
|
|
}),
|
|
},
|
|
|
|
// ---- Escape hatch ----
|
|
{
|
|
name: "gitea_api",
|
|
description: "Make an authenticated Gitea API call for any endpoint without a dedicated tool. Path is relative to /api/v1.",
|
|
input: {
|
|
path: { type: "string", description: "API path relative to /api/v1, e.g. /repos/owner/repo/branches" },
|
|
method: { type: "string", description: "GET | POST | PUT | PATCH | DELETE (default GET)" },
|
|
body: { type: "object", description: "JSON request body for POST/PUT/PATCH" },
|
|
},
|
|
run: async (args) => {
|
|
const method = args.method ? String(args.method) : "GET";
|
|
const result = await gitea.api(String(args.path), {
|
|
method,
|
|
...(args.body ? { body: JSON.stringify(args.body) } : {}),
|
|
});
|
|
return { result };
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
// The tools exist only when a token can be found; without one, gitea contributes none rather than
|
|
// failing the whole runtime.
|
|
registerModuleTools("gitea", (env) => {
|
|
try {
|
|
return getGiteaTools(GiteaClient.fromEnv(env));
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|