feat: consolidated Gitea API client and pi extension

- Pure fetch-based API client (src/) with zero external dependencies
- Pi extension adapter (pi-extension/) registering 17 tools
- Standalone CLI (cli.ts) replacing gitea-scripts/gitea.js
- Token auth everywhere (no HMAC secrets)
- SKILL.md for agent auto-discovery
- TOOL.md with full parameter reference

Consolidates pi-bot/extensions/pi-gitea and clawbot/gitea-scripts
into a single shared package.
This commit is contained in:
2026-03-13 14:49:55 -07:00
commit 25e49db155
19 changed files with 1988 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
/**
* Read-only tools — list/get operations
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { GiteaClient } from "../../src/client.js";
import * as repos from "../../src/repos.js";
import * as issues from "../../src/issues.js";
import * as pulls from "../../src/pulls.js";
import * as actions from "../../src/actions.js";
const client = new GiteaClient();
export default function (pi: ExtensionAPI) {
// ── Repositories ──
pi.registerTool({
name: "gitea_list_repos",
label: "Gitea: List Repositories",
description:
"List repositories. Defaults to repositories accessible to the authenticated user (you). Can specify an owner to list their public repositories.",
parameters: Type.Object({
owner: Type.Optional(Type.String({ description: "Owner to list repos for. Omit for your own repos." })),
limit: Type.Optional(Type.Number({ description: "Max results (default: 50)" })),
}),
async execute(_id, params) {
const result = await repos.listRepos(client, { owner: params.owner, limit: params.limit });
const lines = result.map((r) => {
const vis = r.private ? "🔒" : "🌐";
const updated = r.updated_at?.split("T")[0] ?? "unknown";
return `${vis} ${r.full_name} — ⭐ ${r.stars_count ?? 0} | Updated: ${updated}`;
});
return {
content: [{ type: "text", text: lines.join("\n") || "No repositories found." }],
details: { repos: result, count: result.length },
};
},
});
// ── Issues ──
pi.registerTool({
name: "gitea_list_issues",
label: "Gitea: List Issues",
description: "List issues for a repository.",
parameters: Type.Object({
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
state: Type.Optional(
Type.Union([Type.Literal("open"), Type.Literal("closed"), Type.Literal("all")], {
description: "Issue state (default: open)",
}),
),
limit: Type.Optional(Type.Number({ description: "Max results (default: 20)" })),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const result = await issues.listIssues(client, owner, repo, {
state: params.state as "open" | "closed" | "all",
limit: params.limit,
});
const lines = result.map((i) => `#${i.number} [${i.state}] ${i.title} (@${i.user?.login ?? "?"})`);
return {
content: [{ type: "text", text: lines.join("\n") || "No issues found." }],
details: { issues: result },
};
},
});
pi.registerTool({
name: "gitea_get_issue",
label: "Gitea: Get Issue",
description: "Get an issue by number, including all comments.",
parameters: Type.Object({
index: Type.Number({ description: "Issue number" }),
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const [issue, comments] = await Promise.all([
issues.getIssue(client, owner, repo, params.index),
issues.getIssueComments(client, owner, repo, params.index),
]);
let text = `# Issue #${issue.number}: ${issue.title}\n`;
text += `State: ${issue.state} | Author: @${issue.user?.login ?? "?"} | Created: ${issue.created_at}\n`;
if (issue.labels?.length) text += `Labels: ${issue.labels.map((l) => l.name).join(", ")}\n`;
text += `\n${issue.body ?? "(no body)"}\n`;
if (comments.length > 0) {
text += `\n--- ${comments.length} comment(s) ---\n`;
for (const c of comments) {
text += `\n@${c.user?.login ?? "?"} (${c.created_at}):\n${c.body}\n`;
}
}
return {
content: [{ type: "text", text }],
details: { issue, comments },
};
},
});
pi.registerTool({
name: "gitea_create_issue_comment",
label: "Gitea: Comment on Issue",
description: "Post a comment on an issue or pull request.",
parameters: Type.Object({
index: Type.Number({ description: "Issue/PR number" }),
body: Type.String({ description: "Comment body (Markdown)" }),
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const comment = await issues.createIssueComment(client, owner, repo, params.index, params.body);
return {
content: [{ type: "text", text: `Comment posted: ${comment.html_url}` }],
details: { comment },
};
},
});
// ── Pull Requests ──
pi.registerTool({
name: "gitea_list_prs",
label: "Gitea: List PRs",
description: "List pull requests for a repository.",
parameters: Type.Object({
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
state: Type.Optional(
Type.Union([Type.Literal("open"), Type.Literal("closed"), Type.Literal("all")], {
description: "PR state (default: open)",
}),
),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const result = await pulls.listPullRequests(client, owner, repo, { state: params.state as any });
const lines = result.map(
(p) => `#${p.number} [${p.state}] ${p.title} (@${p.user?.login ?? "?"}) ${p.head?.label ?? ""}${p.base?.label ?? ""}`,
);
return {
content: [{ type: "text", text: lines.join("\n") || "No PRs found." }],
details: { prs: result },
};
},
});
pi.registerTool({
name: "gitea_get_pr",
label: "Gitea: Get PR",
description: "Get a pull request by number, including comments.",
parameters: Type.Object({
index: Type.Number({ description: "PR number" }),
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const [pr, comments] = await Promise.all([
pulls.getPullRequest(client, owner, repo, params.index),
issues.getIssueComments(client, owner, repo, params.index),
]);
let text = `# PR #${pr.number}: ${pr.title}\n`;
text += `State: ${pr.state} | Author: @${pr.user?.login ?? "?"} | Created: ${pr.created_at}\n`;
text += `Base: ${pr.base?.label ?? "?"} ← Head: ${pr.head?.label ?? "?"}\n`;
if (pr.merged) text += `Merged: ${pr.merged_at}\n`;
text += `\n${pr.body ?? "(no body)"}\n`;
if (comments.length > 0) {
text += `\n--- ${comments.length} comment(s) ---\n`;
for (const c of comments) {
text += `\n@${c.user?.login ?? "?"} (${c.created_at}):\n${c.body}\n`;
}
}
return {
content: [{ type: "text", text }],
details: { pr, comments },
};
},
});
// ── Actions / CI ──
pi.registerTool({
name: "gitea_list_runs",
label: "Gitea: List Runs",
description: "List recent workflow runs for a repository.",
parameters: Type.Object({
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
limit: Type.Optional(Type.Number({ description: "Max results (default: 10)" })),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const { runs, total } = await actions.listRuns(client, owner, repo, { limit: params.limit });
const lines = runs.map(
(r) =>
`#${r.run_number} [${r.id}] ${r.conclusion ?? r.status}${r.display_title} (${r.head_branch} / ${r.event}) ${r.completed_at ?? r.started_at}`,
);
return {
content: [{ type: "text", text: lines.join("\n") || "No runs found." }],
details: { runs, total },
};
},
});
pi.registerTool({
name: "gitea_get_run_logs",
label: "Gitea: Get Run Logs",
description: "Get full log output for a workflow run. Fetches all jobs then downloads each job's logs.",
parameters: Type.Object({
run_id: Type.Number({ description: "Workflow run ID (the numeric id, not run_number)" }),
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const { jobs, logs } = await actions.getRunLogs(client, owner, repo, params.run_id);
return {
content: [{ type: "text", text: logs }],
details: { runId: params.run_id, jobs },
};
},
});
}

View File

@@ -0,0 +1,247 @@
/**
* Write tools — create/update operations
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { GiteaClient } from "../../src/client.js";
import * as repos from "../../src/repos.js";
import * as issues from "../../src/issues.js";
import * as pulls from "../../src/pulls.js";
import * as webhooks from "../../src/webhooks.js";
import * as files from "../../src/files.js";
const client = new GiteaClient();
export default function (pi: ExtensionAPI) {
// ── Branches ──
pi.registerTool({
name: "gitea_create_branch",
label: "Gitea: Create Branch",
description: "Create a new branch from an existing ref.",
parameters: Type.Object({
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
branch: Type.String({ description: "Name for the new branch" }),
ref: Type.Optional(Type.String({ description: "Source branch (default: main)" })),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const branch = await files.createBranch(client, owner, repo, {
name: params.branch,
oldRef: params.ref,
});
return {
content: [{ type: "text", text: `Branch created: ${branch.name}` }],
details: { branch },
};
},
});
// ── Files ──
pi.registerTool({
name: "gitea_get_file_content",
label: "Gitea: Get File Content",
description: "Get file content from a repository at a specific path.",
parameters: Type.Object({
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
path: Type.String({ description: "File path (e.g., 'src/index.ts')" }),
ref: Type.Optional(Type.String({ description: "Commit SHA or branch (default: default branch)" })),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const file = await files.getFileContent(client, owner, repo, params.path, { ref: params.ref });
// Decode base64 content
const decoded = file.encoding === "base64" ? Buffer.from(file.content, "base64").toString("utf-8") : file.content;
return {
content: [{ type: "text", text: `File: ${params.path} (ref: ${params.ref ?? "HEAD"}, sha: ${file.sha})\n\n${decoded}` }],
details: { path: params.path, ref: params.ref, sha: file.sha, encoding: file.encoding },
};
},
});
pi.registerTool({
name: "gitea_update_file",
label: "Gitea: Update File",
description: "Update or create a file in a repository. Requires the file's current SHA for updates.",
parameters: Type.Object({
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
path: Type.String({ description: "File path" }),
content: Type.String({ description: "File content" }),
message: Type.String({ description: "Commit message" }),
branch: Type.Optional(Type.String({ description: "Target branch (default: main)" })),
sha: Type.Optional(Type.String({ description: "Current file SHA (required for updates)" })),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const result = await files.updateFile(client, owner, repo, params.path, {
content: params.content,
message: params.message,
branch: params.branch,
sha: params.sha,
});
return {
content: [{ type: "text", text: `File updated: ${params.path}` }],
details: { commit: result.commit, content: result.content },
};
},
});
// ── Repos ──
pi.registerTool({
name: "gitea_create_repo",
label: "Gitea: Create Repository",
description: "Create a new repository for the authenticated user.",
parameters: Type.Object({
name: Type.String({ description: "Repository name" }),
private: Type.Optional(Type.Boolean({ description: "Private repo (default: false)" })),
description: Type.Optional(Type.String({ description: "Repository description" })),
}),
async execute(_id, params) {
const repo = await repos.createRepo(client, {
name: params.name,
private: params.private,
description: params.description,
});
return {
content: [{ type: "text", text: `✅ Created: ${repo.html_url}` }],
details: { repo },
};
},
});
pi.registerTool({
name: "gitea_ensure_repo",
label: "Gitea: Ensure Repository",
description: "Get a repository if it exists, create it if not. Returns clone URL.",
parameters: Type.Object({
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
name: Type.String({ description: "Repository name" }),
private: Type.Optional(Type.Boolean({ description: "Private if creating (default: false)" })),
}),
async execute(_id, params) {
const owner = params.owner ?? client.defaultOwner;
const { repo, created } = await repos.ensureRepo(client, owner, params.name, {
private: params.private,
});
return {
content: [{ type: "text", text: `${created ? "Created" : "Exists"}: ${repo.clone_url}` }],
details: { repo, created },
};
},
});
// ── Issues ──
pi.registerTool({
name: "gitea_create_issue",
label: "Gitea: Create Issue",
description: "Create a new issue in a repository.",
parameters: Type.Object({
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
title: Type.String({ description: "Issue title" }),
body: Type.Optional(Type.String({ description: "Issue body (Markdown)" })),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const issue = await issues.createIssue(client, owner, repo, {
title: params.title,
body: params.body,
});
return {
content: [{ type: "text", text: `✅ Issue #${issue.number}: ${issue.html_url}` }],
details: { issue },
};
},
});
// ── Pull Requests ──
pi.registerTool({
name: "gitea_create_pr",
label: "Gitea: Create PR",
description: "Create a pull request from a source branch to a target branch.",
parameters: Type.Object({
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
title: Type.String({ description: "PR title" }),
head: Type.String({ description: "Source branch" }),
base: Type.String({ description: "Target branch" }),
body: Type.Optional(Type.String({ description: "PR description" })),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const pr = await pulls.createPullRequest(client, owner, repo, {
title: params.title,
head: params.head,
base: params.base,
body: params.body,
});
return {
content: [{ type: "text", text: `PR created: ${pr.html_url}` }],
details: { pr },
};
},
});
pi.registerTool({
name: "gitea_merge_pr",
label: "Gitea: Merge PR",
description: "Merge a pull request into its base branch.",
parameters: Type.Object({
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
index: Type.Number({ description: "PR number" }),
merge_method: Type.Optional(
Type.Union([Type.Literal("merge"), Type.Literal("rebase"), Type.Literal("squash")], {
description: "Merge method (default: merge)",
}),
),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
await pulls.mergePullRequest(client, owner, repo, params.index, {
method: params.merge_method as any,
});
return {
content: [{ type: "text", text: `PR #${params.index} merged.` }],
details: { index: params.index },
};
},
});
// ── Webhooks ──
pi.registerTool({
name: "gitea_create_webhook",
label: "Gitea: Create Webhook",
description: "Create a webhook on a repository.",
parameters: Type.Object({
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
url: Type.String({ description: "URL where Gitea will send webhooks" }),
token: Type.Optional(Type.String({ description: "Bearer token for webhook auth" })),
events: Type.Optional(
Type.Array(Type.String(), { description: "Events to listen for (default: issues, issue_comment, pull_request, push)" }),
),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const hook = await webhooks.createWebhook(client, owner, repo, {
url: params.url,
token: params.token,
events: params.events,
});
return {
content: [{ type: "text", text: `Webhook created: ID ${hook.id}` }],
details: { webhook: hook },
};
},
});
}