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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
*.js.map

70
README.md Normal file
View File

@@ -0,0 +1,70 @@
# gitea
Consolidated Gitea API client and pi extension for git.dominat.us.
Provides a pure TypeScript Gitea API client (no external dependencies), a pi extension that registers tools for LLM use, and a standalone CLI.
## Structure
```
src/ Core API client (pure fetch, token auth, zero deps)
pi-extension/ Pi extension adapter (registers tools + webhook server)
cli.ts Standalone CLI
SKILL.md Agent skill definition (auto-discovered by pi)
TOOL.md Tool reference documentation
```
## Installation
### As a pi extension
Add to your `settings.json`:
```json
{
"packages": ["/path/to/gitea/pi-extension"]
}
```
Or place/symlink `pi-extension/` into your `<agentDir>/extensions/` directory.
### As a library
```typescript
import { GiteaClient, listRepos, getIssue } from "./src/index.js";
const client = new GiteaClient({
url: "https://git.dominat.us",
token: "your-token",
});
const repos = await listRepos(client);
const issue = await getIssue(client, "owner", "repo", 1);
```
### As a CLI
```bash
export GITEA_TOKEN="your-token"
npx tsx cli.ts whoami
npx tsx cli.ts repos
npx tsx cli.ts issues owner/repo
npx tsx cli.ts create-issue owner/repo "Bug title" --body "Description"
```
## Configuration
| Variable | Purpose | Default |
|----------|---------|---------|
| `GITEA_URL` | Gitea instance URL | `https://git.dominat.us` |
| `GITEA_TOKEN` | API token (required) | — |
| `GITEA_OWNER` | Default repo owner | — |
| `GITEA_REPO` | Default repo name | — |
See [TOOL.md](TOOL.md) for full tool documentation and [SKILL.md](SKILL.md) for the agent skill definition.
## Auth
All API calls use `Authorization: token <GITEA_TOKEN>` header. No HMAC secrets.
Webhook endpoint optionally validates with `Authorization: Bearer <PI_WEBHOOK_TOKEN>`.

84
SKILL.md Normal file
View File

@@ -0,0 +1,84 @@
---
name: gitea
description: Interact with Gitea repositories, issues, PRs, CI runs, and files via git.dominat.us. Use when working with Gitea repos, triaging issues, reviewing PRs, checking CI logs, creating branches, updating files, or managing webhooks.
---
# Gitea
Tools for interacting with a Gitea instance (git.dominat.us by default).
## When to Use
- Listing, viewing, or searching repositories
- Reading or triaging issues
- Reviewing or creating pull requests
- Checking CI workflow runs and logs
- Reading or updating files in a repo via the API
- Creating branches for feature work
- Setting up webhooks for event notifications
## Quick Reference
See [TOOL.md](TOOL.md) for full parameter details on every tool.
### Read Operations
| Tool | Purpose |
|------|---------|
| `gitea_list_repos` | List repos (yours or another owner's) |
| `gitea_list_issues` | List issues on a repo |
| `gitea_get_issue` | Get issue details + comments |
| `gitea_list_prs` | List pull requests |
| `gitea_get_pr` | Get PR details + comments |
| `gitea_list_runs` | List CI workflow runs |
| `gitea_get_run_logs` | Get full CI logs for a run |
| `gitea_get_file_content` | Read a file from a repo |
### Write Operations
| Tool | Purpose |
|------|---------|
| `gitea_create_repo` | Create a new repository |
| `gitea_ensure_repo` | Get or create a repository |
| `gitea_create_issue` | Open a new issue |
| `gitea_create_issue_comment` | Comment on an issue or PR |
| `gitea_create_branch` | Create a branch |
| `gitea_update_file` | Create or update a file |
| `gitea_create_pr` | Open a pull request |
| `gitea_merge_pr` | Merge a pull request |
| `gitea_create_webhook` | Add a webhook to a repo |
## Common Workflows
### Triage an Issue
1. `gitea_list_issues` to see open issues
2. `gitea_get_issue` to read details and comments
3. `gitea_create_issue_comment` to respond
### Fix an Issue via PR
1. `gitea_get_issue` to understand the problem
2. `gitea_create_branch` to create a feature branch
3. `gitea_get_file_content` to read the file that needs fixing
4. `gitea_update_file` to push the fix (on the feature branch)
5. `gitea_create_pr` to open a PR referencing the issue
6. `gitea_create_issue_comment` to note the fix
### Check CI Status
1. `gitea_list_runs` to see recent workflow runs
2. `gitea_get_run_logs` to read logs for a failing run
## Configuration
All tools use these environment variables for defaults:
| Variable | Purpose | Default |
|----------|---------|---------|
| `GITEA_URL` | Gitea instance URL | `https://git.dominat.us` |
| `GITEA_TOKEN` | API token (required) | — |
| `GITEA_OWNER` | Default repo owner | — |
| `GITEA_REPO` | Default repo name | — |
Most tools accept `owner` and `repo` parameters to override defaults per-call.

222
TOOL.md Normal file
View File

@@ -0,0 +1,222 @@
# Gitea Tools Reference
All tools use token-based authentication via `GITEA_TOKEN` environment variable.
Owner and repo parameters default to `GITEA_OWNER` and `GITEA_REPO` env vars when omitted.
---
## Read Tools
### `gitea_list_repos`
List repositories accessible to you, or public repos for a specific owner.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `owner` | string | No | authenticated user | Owner to list repos for |
| `limit` | number | No | 50 | Max results |
### `gitea_list_issues`
List issues for a repository.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
| `state` | `"open"` \| `"closed"` \| `"all"` | No | `"open"` | Issue state filter |
| `limit` | number | No | 20 | Max results |
### `gitea_get_issue`
Get a single issue by number, including all comments.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `index` | number | **Yes** | — | Issue number |
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
**Returns:** Issue title, state, author, labels, body, and all comments.
### `gitea_list_prs`
List pull requests for a repository.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
| `state` | `"open"` \| `"closed"` \| `"all"` | No | `"open"` | PR state filter |
### `gitea_get_pr`
Get a single pull request by number, including comments.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `index` | number | **Yes** | — | PR number |
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
**Returns:** PR title, state, author, head/base branches, merge status, body, and all comments.
### `gitea_list_runs`
List recent CI/Actions workflow runs.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
| `limit` | number | No | 10 | Max results |
### `gitea_get_run_logs`
Get full log output for a workflow run (fetches all jobs, downloads each job's logs).
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `run_id` | number | **Yes** | — | Workflow run ID (numeric id, not run_number) |
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
### `gitea_get_file_content`
Get file content from a repository.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `path` | string | **Yes** | — | File path (e.g., `src/index.ts`) |
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
| `ref` | string | No | default branch | Commit SHA or branch name |
**Returns:** Decoded file content, SHA (needed for updates), and ref.
---
## Write Tools
### `gitea_create_repo`
Create a new repository for the authenticated user.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `name` | string | **Yes** | — | Repository name |
| `private` | boolean | No | `false` | Private repository |
| `description` | string | No | `""` | Repository description |
### `gitea_ensure_repo`
Get a repository if it exists, create it if not. Returns clone URL.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `name` | string | **Yes** | — | Repository name |
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `private` | boolean | No | `false` | Private if creating |
### `gitea_create_issue`
Create a new issue in a repository.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `title` | string | **Yes** | — | Issue title |
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
| `body` | string | No | `""` | Issue body (Markdown) |
### `gitea_create_issue_comment`
Post a comment on an issue or pull request.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `index` | number | **Yes** | — | Issue/PR number |
| `body` | string | **Yes** | — | Comment body (Markdown) |
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
### `gitea_create_branch`
Create a new branch from an existing ref.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `branch` | string | **Yes** | — | Name for the new branch |
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
| `ref` | string | No | `"main"` | Source branch |
### `gitea_update_file`
Create or update a file in a repository. For updates, you must provide the current file's SHA (get it from `gitea_get_file_content`).
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `path` | string | **Yes** | — | File path |
| `content` | string | **Yes** | — | File content |
| `message` | string | **Yes** | — | Commit message |
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
| `branch` | string | No | `"main"` | Target branch |
| `sha` | string | No | — | Current file SHA (required for updates) |
### `gitea_create_pr`
Create a pull request.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `title` | string | **Yes** | — | PR title |
| `head` | string | **Yes** | — | Source branch |
| `base` | string | **Yes** | — | Target branch |
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
| `body` | string | No | `""` | PR description |
### `gitea_merge_pr`
Merge a pull request.
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `index` | number | **Yes** | — | PR number |
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
| `merge_method` | `"merge"` \| `"rebase"` \| `"squash"` | No | `"merge"` | Merge strategy |
### `gitea_create_webhook`
Create a webhook on a repository. Uses bearer token auth (not HMAC secrets).
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `url` | string | **Yes** | — | Webhook delivery URL |
| `owner` | string | No | `GITEA_OWNER` | Repo owner |
| `repo` | string | No | `GITEA_REPO` | Repo name |
| `token` | string | No | — | Bearer token for webhook auth |
| `events` | string[] | No | `["issues", "issue_comment", "pull_request", "push"]` | Events to listen for |
---
## Authentication
All API calls use `Authorization: token <GITEA_TOKEN>` header.
Webhook endpoint uses `Authorization: Bearer <PI_WEBHOOK_TOKEN>` for incoming request validation (optional — if not set, endpoint is open).
| Variable | Purpose |
|----------|---------|
| `GITEA_URL` | Gitea instance URL (default: `https://git.dominat.us`) |
| `GITEA_TOKEN` | Gitea API token (**required**) |
| `GITEA_OWNER` | Default repo owner |
| `GITEA_REPO` | Default repo name |
| `PI_WEBHOOK_TOKEN` | Bearer token to validate inbound webhooks (optional) |
| `PI_WEBHOOK_HOST` | Webhook server bind host (default: `0.0.0.0`) |
| `PI_WEBHOOK_PORT` | Webhook server port (default: `3000`) |
| `PI_WEBHOOK_URL` | Public URL for webhook registration (used by auto-polling) |
| `PI_BOT_POLL_INTERVAL` | Polling interval in seconds for new repos (default: `300`) |

187
cli.ts Normal file
View File

@@ -0,0 +1,187 @@
#!/usr/bin/env node
/**
* Gitea CLI — standalone command-line interface
*
* Usage: gitea <command> [options]
* Replaces gitea-scripts/gitea.js with the consolidated core.
*/
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";
import * as webhooks from "./src/webhooks.js";
import * as files from "./src/files.js";
const client = new GiteaClient();
function parseArgs(argv: string[]): { positional: string[]; flags: Record<string, string | boolean> } {
const positional: string[] = [];
const flags: Record<string, string | boolean> = {};
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith("--")) {
const key = argv[i].slice(2);
const next = argv[i + 1];
if (next && !next.startsWith("--")) {
flags[key] = next;
i++;
} else {
flags[key] = true;
}
} else {
positional.push(argv[i]);
}
}
return { positional, flags };
}
function splitOwnerRepo(input: string): { owner: string; repo: string } {
const parts = input.split("/");
if (parts.length === 2) return { owner: parts[0], repo: parts[1] };
return { owner: client.defaultOwner, repo: input };
}
const commands: Record<string, (args: string[]) => Promise<void>> = {
async whoami() {
const user = await client.get<{ login: string; email: string }>("/user");
console.log(JSON.stringify(user, null, 2));
},
async repos(args) {
const { flags } = parseArgs(args);
const result = await repos.listRepos(client, {
owner: flags.owner as string,
limit: flags.limit ? parseInt(flags.limit as string) : undefined,
});
for (const r of result) {
console.log(`${r.full_name} [${r.private ? "private" : "public"}]`);
}
},
async "create-repo"(args) {
const { positional, flags } = parseArgs(args);
const name = positional[0];
if (!name) { console.error("Usage: create-repo <name> [--private] [--description ...]"); process.exit(1); }
const repo = await repos.createRepo(client, {
name,
private: !!flags.private,
description: flags.description as string,
});
console.log(`✅ Created: ${repo.html_url}`);
},
async "ensure-repo"(args) {
const { positional, flags } = parseArgs(args);
const name = positional[0];
if (!name) { console.error("Usage: ensure-repo <name> [--private]"); process.exit(1); }
const { repo, created } = await repos.ensureRepo(client, client.defaultOwner, name, {
private: !!flags.private,
});
console.log(`${created ? "created" : "exists"}: ${repo.clone_url}`);
},
async issues(args) {
const { positional } = parseArgs(args);
const target = positional[0];
if (!target) { console.error("Usage: issues <owner/repo>"); process.exit(1); }
const { owner, repo } = splitOwnerRepo(target);
const list = await issues.listIssues(client, owner, repo);
for (const i of list) {
console.log(`#${i.number} [${i.state}] ${i.title}`);
}
},
async "create-issue"(args) {
const { positional, flags } = parseArgs(args);
const target = positional[0];
const title = positional[1];
if (!target || !title) { console.error("Usage: create-issue <owner/repo> <title> [--body ...]"); process.exit(1); }
const { owner, repo } = splitOwnerRepo(target);
const issue = await issues.createIssue(client, owner, repo, {
title,
body: flags.body as string,
});
console.log(`✅ Issue #${issue.number}: ${issue.html_url}`);
},
async prs(args) {
const { positional } = parseArgs(args);
const target = positional[0];
if (!target) { console.error("Usage: prs <owner/repo>"); process.exit(1); }
const { owner, repo } = splitOwnerRepo(target);
const list = await pulls.listPullRequests(client, owner, repo);
for (const p of list) {
console.log(`#${p.number} [${p.state}] ${p.title} (${p.head?.label}${p.base?.label})`);
}
},
async runs(args) {
const { positional } = parseArgs(args);
const target = positional[0];
if (!target) { console.error("Usage: runs <owner/repo>"); process.exit(1); }
const { owner, repo } = splitOwnerRepo(target);
const { runs } = await actions.listRuns(client, owner, repo);
for (const r of runs) {
console.log(`#${r.run_number} [${r.id}] ${r.conclusion ?? r.status}${r.display_title} (${r.head_branch})`);
}
},
async "add-webhook"(args) {
const { positional, flags } = parseArgs(args);
const target = positional[0];
const webhookUrl = positional[1];
if (!target || !webhookUrl) { console.error("Usage: add-webhook <owner/repo> <url> [--token ...]"); process.exit(1); }
const { owner, repo } = splitOwnerRepo(target);
const hook = await webhooks.createWebhook(client, owner, repo, {
url: webhookUrl,
token: flags.token as string,
});
console.log(`✅ Webhook created: ${hook.id}`);
},
async help() {
console.log(`
Gitea CLI — ${client.url}
Commands:
whoami Show current user
repos [--owner ...] [--limit N] List repos
create-repo <name> [--private] [--description "..."]
ensure-repo <name> [--private] Create if not exists
issues <owner/repo> List issues
create-issue <owner/repo> <title> [--body "..."]
prs <owner/repo> List pull requests
runs <owner/repo> List workflow runs
add-webhook <owner/repo> <url> [--token "..."]
Env: GITEA_URL, GITEA_TOKEN, GITEA_OWNER, GITEA_REPO
`);
},
};
async function main() {
if (!client.token) {
console.error("❌ No GITEA_TOKEN found. Set GITEA_TOKEN in environment.");
process.exit(1);
}
const cmd = process.argv[2];
if (!cmd || cmd === "--help" || cmd === "help") {
await commands.help([]);
return;
}
const fn = commands[cmd];
if (!fn) {
console.error(`Unknown command: ${cmd}`);
process.exit(1);
}
await fn(process.argv.slice(3));
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "gitea",
"version": "1.0.0",
"description": "Gitea API client and pi extension for git.dominat.us",
"type": "module",
"exports": {
".": "./src/index.ts",
"./cli": "./cli.ts"
},
"bin": {
"gitea": "./cli.ts"
},
"scripts": {
"cli": "npx tsx cli.ts"
}
}

35
pi-extension/index.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* pi-gitea Extension — entry point
*
* Registers Gitea tools (read + write) and optional webhook server.
*/
import registerReadTools from "./tools/read-tools.js";
import registerWriteTools from "./tools/write-tools.js";
import { startWebhookServer, stopWebhookServer, startPolling, stopPolling, setSendMessage } from "./webhook/server.js";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: ExtensionAPI) {
registerReadTools(pi);
registerWriteTools(pi);
pi.on("session_start", async (_event, ctx) => {
console.log("[pi-gitea] Session started");
const sendMessageFn = ctx.sendUserMessage || ((_msg: string) => Promise.resolve());
setSendMessage(sendMessageFn);
try {
await startWebhookServer(pi);
startPolling(pi);
} catch (err) {
console.error("[pi-gitea] Failed to start webhook server:", err);
}
});
pi.on("session_shutdown", async () => {
console.log("[pi-gitea] Session shutting down");
await stopWebhookServer();
stopPolling();
});
}

View File

@@ -0,0 +1,9 @@
{
"name": "pi-gitea",
"private": true,
"version": "1.0.0",
"type": "module",
"pi": {
"extensions": ["./index.ts"]
}
}

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 },
};
},
});
}

View File

@@ -0,0 +1,286 @@
/**
* Webhook server — receives Gitea events via HTTP
*
* Auth: Bearer token validation (PI_WEBHOOK_TOKEN).
* No HMAC/secret — consistent with token-based auth strategy.
*/
import type { Server, IncomingMessage, ServerResponse } from "node:http";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { GiteaClient } from "../../src/client.js";
const WEBHOOK_HOST = process.env.PI_WEBHOOK_HOST ?? "0.0.0.0";
const WEBHOOK_PORT = parseInt(process.env.PI_WEBHOOK_PORT ?? "3000", 10);
const WEBHOOK_TOKEN = process.env.PI_WEBHOOK_TOKEN ?? "";
const WEBHOOK_URL = process.env.PI_WEBHOOK_URL ?? "";
const POLL_INTERVAL = parseInt(process.env.PI_BOT_POLL_INTERVAL ?? "300", 10);
let server: Server | null = null;
let trackedRepos: Map<string, { webhookId: number; addedAt: number }> = new Map();
let processingQueue: Array<{ event: any; timestamp: number }> = [];
let maxQueueDepth = 50;
let isProcessing = false;
let pollTimer: NodeJS.Timeout | null = null;
let sendMessage: ((message: string) => Promise<void>) | null = null;
export function setSendMessage(fn: (message: string) => Promise<void>) {
sendMessage = fn;
}
/** Validate bearer token on incoming webhook request */
function validateToken(req: IncomingMessage): boolean {
if (!WEBHOOK_TOKEN) return true; // No token configured = open (localhost only)
const auth = req.headers["authorization"];
if (!auth) return false;
return auth === `Bearer ${WEBHOOK_TOKEN}`;
}
/** Format a Gitea event as a prompt for the LLM */
function formatEventPrompt(event: any): string {
const action = event.action;
const repo = event.repository?.full_name || "unknown";
let prompt = `New Gitea event on ${repo}:\n\n`;
prompt += `**Action**: ${action}\n\n`;
if (event.issue) {
const issue = event.issue;
prompt += `**Issue #${issue.number}: ${issue.title}**\n`;
prompt += `**Author**: @${issue.user?.login || "unknown"}\n`;
prompt += `**Labels**: ${issue.labels?.map((l: any) => l.name).join(", ") || "none"}\n`;
prompt += `**Body**:\n${issue.body || "(no body)"}\n\n`;
}
if (event.pull_request) {
const pr = event.pull_request;
prompt += `**PR #${pr.number}: ${pr.title}**\n`;
prompt += `**Author**: @${pr.user?.login || "unknown"}\n`;
prompt += `**Base**: ${pr.base?.label} ← **Head**: ${pr.head?.label}\n`;
prompt += `**Body**:\n${pr.body || "(no body)"}\n\n`;
}
if (event.comment) {
const comment = event.comment;
const targetNumber = event.issue?.number || event.pull_request?.number;
prompt += `**Comment on #${targetNumber}**\n`;
prompt += `**Author**: @${comment.user?.login || "unknown"}\n`;
prompt += `**Body**:\n${comment.body || "(no body)"}\n\n`;
}
if (event.pusher) {
prompt += `**Pusher**: @${event.pusher.name}\n`;
prompt += `**Commits**: ${event.commits?.length || 0}\n\n`;
}
prompt += `---\n\n`;
prompt += `Please analyze this event and decide how to respond. You can:\n`;
prompt += `1. Add helpful comments to issues/PRs\n`;
prompt += `2. Suggest code fixes or improvements\n`;
prompt += `3. Create branches and PRs to fix issues\n`;
prompt += `4. Update files directly (if direct_push is enabled)\n`;
prompt += `5. Ask for clarification if needed\n\n`;
prompt += `Use the available Gitea tools to interact with the repository.`;
return prompt;
}
/** Process the event queue */
async function processQueue() {
if (isProcessing || processingQueue.length === 0) return;
isProcessing = true;
while (processingQueue.length > 0) {
const { event } = processingQueue.shift()!;
const repoName = event.repository?.full_name || "unknown";
console.log(`[gitea-webhook] Processing event: ${event.action} on ${repoName}`);
if (sendMessage) {
try {
const prompt = formatEventPrompt(event);
await sendMessage(prompt);
console.log(`[gitea-webhook] Event sent to LLM: ${event.action} on ${repoName}`);
} catch (err) {
console.error(`[gitea-webhook] Failed to send event to LLM:`, err);
}
} else {
console.warn(`[gitea-webhook] No sendMessage function available, skipping event`);
}
}
isProcessing = false;
}
/** Start webhook server */
export function startWebhookServer(_pi: ExtensionAPI) {
return new Promise<void>(async (resolve, reject) => {
try {
const http = await import("node:http");
server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => {
const url = req.url || "";
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
res.writeHead(200);
res.end();
return;
}
// GET /health
if (url === "/health" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: "ok",
uptime: process.uptime(),
tracked_repos: trackedRepos.size,
queue_depth: processingQueue.length,
is_processing: isProcessing,
}),
);
return;
}
// POST /hooks/gitea
if (url === "/hooks/gitea" && req.method === "POST") {
// Validate token
if (!validateToken(req)) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Unauthorized" }));
console.error("[gitea-webhook] Token validation failed");
return;
}
// Read body
let body = "";
for await (const chunk of req) {
body += chunk.toString();
}
// Parse event
let event;
try {
event = JSON.parse(body);
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid JSON" }));
return;
}
// Queue event
processingQueue.push({ event, timestamp: Date.now() });
if (processingQueue.length > maxQueueDepth) {
const dropped = processingQueue.shift();
if (dropped) {
console.warn(`[gitea-webhook] Queue full, dropping oldest event`);
}
}
void processQueue();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ received: true, event: event.action }));
return;
}
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not found");
});
server.listen(WEBHOOK_PORT, WEBHOOK_HOST, () => {
console.log(`[gitea-webhook] Server listening on ${WEBHOOK_HOST}:${WEBHOOK_PORT}`);
resolve();
});
server.on("error", reject);
} catch (err) {
reject(err);
}
});
}
/** Stop webhook server */
export async function stopWebhookServer() {
return new Promise<void>((resolve) => {
if (server) {
server.close(() => {
console.log("[gitea-webhook] Server stopped");
server = null;
resolve();
});
} else {
resolve();
}
});
}
/** Poll for new repos and register webhooks */
export function startPolling(_pi: ExtensionAPI) {
void fetchUserRepos();
pollTimer = setInterval(() => void fetchUserRepos(), POLL_INTERVAL * 1000);
console.log(`[gitea-polling] Polling started (interval: ${POLL_INTERVAL}s)`);
}
export function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function fetchUserRepos() {
try {
const client = new GiteaClient();
if (!WEBHOOK_URL) {
console.warn("[gitea-polling] PI_WEBHOOK_URL not set, skipping webhook registration");
return;
}
const repos = await client.get<any[]>("/user/repos?limit=100");
const newRepos: any[] = [];
for (const repo of repos) {
if (!trackedRepos.has(repo.full_name)) {
newRepos.push(repo);
}
}
if (newRepos.length > 0) {
console.log(`[gitea-polling] Found ${newRepos.length} new repos, registering webhooks...`);
for (const repo of newRepos) {
try {
const webhook = await client.post<any>(`/repos/${repo.full_name}/hooks`, {
type: "gitea",
config: {
url: `${WEBHOOK_URL}/hooks/gitea`,
content_type: "json",
...(WEBHOOK_TOKEN ? { authorization: `Bearer ${WEBHOOK_TOKEN}` } : {}),
},
events: ["issues", "issue_comment", "pull_request", "push"],
active: true,
});
trackedRepos.set(repo.full_name, { webhookId: webhook.id, addedAt: Date.now() });
console.log(`[gitea-polling] Webhook created for ${repo.full_name} (ID: ${webhook.id})`);
} catch (err) {
console.error(`[gitea-polling] Error creating webhook for ${repo.full_name}:`, err);
}
}
}
console.log(`[gitea-polling] Current repos: ${trackedRepos.size}`);
} catch (err) {
console.error("[gitea-polling] Error fetching user repos:", err);
}
}
export function getTrackedRepos() {
return new Map(trackedRepos);
}

86
src/actions.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* CI / Actions operations
*/
import type { GiteaClient } from "./client.js";
export interface WorkflowRun {
id: number;
run_number: number;
display_title: string;
status: string;
conclusion: string;
head_branch: string;
head_sha: string;
started_at: string;
completed_at: string;
event: string;
}
export interface Job {
id: number;
name: string;
status: string;
conclusion: string;
}
/** List workflow runs */
export async function listRuns(
client: GiteaClient,
owner: string,
repo: string,
opts: { limit?: number } = {},
): Promise<{ runs: WorkflowRun[]; total: number }> {
const limit = opts.limit ?? 10;
const data = await client.get<{ workflow_runs: WorkflowRun[]; total_count: number }>(
`/repos/${owner}/${repo}/actions/runs?limit=${limit}`,
);
return { runs: data.workflow_runs ?? [], total: data.total_count ?? 0 };
}
/** List jobs for a workflow run */
export async function listRunJobs(
client: GiteaClient,
owner: string,
repo: string,
runId: number,
): Promise<Job[]> {
const data = await client.get<{ jobs: Job[] }>(`/repos/${owner}/${repo}/actions/runs/${runId}/jobs`);
return data.jobs ?? [];
}
/** Get logs for a job */
export async function getJobLogs(
client: GiteaClient,
owner: string,
repo: string,
jobId: number,
): Promise<string> {
return client.getText(`/repos/${owner}/${repo}/actions/jobs/${jobId}/logs`);
}
/** Get full logs for a workflow run (all jobs) */
export async function getRunLogs(
client: GiteaClient,
owner: string,
repo: string,
runId: number,
): Promise<{ jobs: Job[]; logs: string }> {
const jobs = await listRunJobs(client, owner, repo, runId);
if (jobs.length === 0) {
return { jobs, logs: "No jobs found for this run." };
}
const sections: string[] = [];
for (const job of jobs) {
sections.push(`\n=== Job: ${job.name} [${job.id}] — ${job.conclusion ?? job.status} ===\n`);
try {
sections.push(await getJobLogs(client, owner, repo, job.id));
} catch (e) {
sections.push(`(failed to fetch logs: ${e instanceof Error ? e.message : String(e)})`);
}
}
return { jobs, logs: sections.join("") };
}

125
src/client.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* Gitea API client — pure fetch, token auth.
*
* All API calls use `Authorization: token <GITEA_TOKEN>` header.
* No external dependencies.
*/
export interface GiteaClientOptions {
url?: string;
token?: string;
owner?: string;
repo?: string;
}
export interface GiteaResponse<T = unknown> {
status: number;
data: T;
}
export class GiteaClient {
readonly url: string;
readonly token: string;
readonly defaultOwner: string;
readonly defaultRepo: string;
constructor(opts: GiteaClientOptions = {}) {
this.url = (opts.url ?? process.env.GITEA_URL ?? "https://git.dominat.us").replace(/\/$/, "");
this.token = opts.token ?? process.env.GITEA_TOKEN ?? process.env.PI_GIT_TOKEN ?? "";
this.defaultOwner = opts.owner ?? process.env.GITEA_OWNER ?? process.env.DEFAULT_OWNER ?? "";
this.defaultRepo = opts.repo ?? process.env.GITEA_REPO ?? process.env.DEFAULT_REPO ?? "";
}
/** Resolve owner/repo with defaults */
resolve(owner?: string, repo?: string): { owner: string; repo: string } {
return {
owner: owner ?? this.defaultOwner,
repo: repo ?? this.defaultRepo,
};
}
/** Raw API request returning parsed JSON */
async request<T = unknown>(method: string, endpoint: string, body?: unknown): Promise<GiteaResponse<T>> {
const url = `${this.url}/api/v1${endpoint}`;
const headers: Record<string, string> = {
Authorization: `token ${this.token}`,
Accept: "application/json",
};
const init: RequestInit = { method, headers };
if (body !== undefined) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
const res = await fetch(url, init);
const text = await res.text();
let data: T;
try {
data = text ? JSON.parse(text) : (null as T);
} catch {
data = text as unknown as T;
}
if (!res.ok) {
const msg = typeof data === "object" && data && "message" in data
? (data as { message: string }).message
: text;
throw new GiteaError(res.status, msg, endpoint);
}
return { status: res.status, data };
}
/** GET request returning parsed JSON */
async get<T = unknown>(endpoint: string): Promise<T> {
return (await this.request<T>("GET", endpoint)).data;
}
/** GET request returning raw text */
async getText(endpoint: string): Promise<string> {
const url = `${this.url}/api/v1${endpoint}`;
const res = await fetch(url, {
headers: {
Authorization: `token ${this.token}`,
},
});
if (!res.ok) {
throw new GiteaError(res.status, await res.text(), endpoint);
}
return res.text();
}
/** POST request */
async post<T = unknown>(endpoint: string, body: unknown): Promise<T> {
return (await this.request<T>("POST", endpoint, body)).data;
}
/** PUT request */
async put<T = unknown>(endpoint: string, body: unknown): Promise<T> {
return (await this.request<T>("PUT", endpoint, body)).data;
}
/** DELETE request */
async delete(endpoint: string): Promise<void> {
await this.request("DELETE", endpoint);
}
/** PATCH request */
async patch<T = unknown>(endpoint: string, body: unknown): Promise<T> {
return (await this.request<T>("PATCH", endpoint, body)).data;
}
}
export class GiteaError extends Error {
constructor(
public readonly status: number,
public readonly body: string,
public readonly endpoint: string,
) {
super(`Gitea API ${status} on ${endpoint}: ${body}`);
this.name = "GiteaError";
}
}

91
src/files.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* File / content operations
*/
import type { GiteaClient } from "./client.js";
export interface FileContent {
name: string;
path: string;
sha: string;
content: string;
encoding: string;
size: number;
html_url: string;
}
export interface FileCommitResponse {
content: FileContent;
commit: { sha: string; html_url: string };
}
export interface Branch {
name: string;
commit: { id: string; message: string };
}
/** Get file content from a repo */
export async function getFileContent(
client: GiteaClient,
owner: string,
repo: string,
filepath: string,
opts: { ref?: string } = {},
): Promise<FileContent> {
const ref = opts.ref ? `?ref=${encodeURIComponent(opts.ref)}` : "";
return client.get<FileContent>(`/repos/${owner}/${repo}/contents/${filepath}${ref}`);
}
/** Create or update a file in a repo */
export async function updateFile(
client: GiteaClient,
owner: string,
repo: string,
filepath: string,
opts: { content: string; message: string; branch?: string; sha?: string },
): Promise<FileCommitResponse> {
return client.put<FileCommitResponse>(`/repos/${owner}/${repo}/contents/${filepath}`, {
content: Buffer.from(opts.content).toString("base64"),
message: opts.message,
branch: opts.branch,
sha: opts.sha,
});
}
/** Delete a file in a repo */
export async function deleteFile(
client: GiteaClient,
owner: string,
repo: string,
filepath: string,
opts: { sha: string; message: string; branch?: string },
): Promise<void> {
// Gitea's delete file endpoint uses DELETE with a body
await client.request("DELETE", `/repos/${owner}/${repo}/contents/${filepath}`, {
sha: opts.sha,
message: opts.message,
branch: opts.branch,
});
}
/** Create a branch */
export async function createBranch(
client: GiteaClient,
owner: string,
repo: string,
opts: { name: string; oldRef?: string },
): Promise<Branch> {
return client.post<Branch>(`/repos/${owner}/${repo}/branches`, {
new_branch_name: opts.name,
old_branch_name: opts.oldRef ?? "main",
});
}
/** List branches */
export async function listBranches(
client: GiteaClient,
owner: string,
repo: string,
): Promise<Branch[]> {
return client.get<Branch[]>(`/repos/${owner}/${repo}/branches`);
}

13
src/index.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Gitea API client — consolidated package
*
* Pure fetch-based, token auth, no external dependencies.
*/
export { GiteaClient, GiteaError, type GiteaClientOptions, type GiteaResponse } from "./client.js";
export * from "./repos.js";
export * from "./issues.js";
export * from "./pulls.js";
export * from "./actions.js";
export * from "./webhooks.js";
export * from "./files.js";

78
src/issues.ts Normal file
View File

@@ -0,0 +1,78 @@
/**
* Issue operations
*/
import type { GiteaClient } from "./client.js";
export interface Issue {
id: number;
number: number;
title: string;
body: string;
state: string;
user: { login: string };
labels: Array<{ name: string; color: string }>;
created_at: string;
updated_at: string;
html_url: string;
}
export interface Comment {
id: number;
body: string;
user: { login: string };
created_at: string;
html_url: string;
}
/** List issues for a repository */
export async function listIssues(
client: GiteaClient,
owner: string,
repo: string,
opts: { state?: "open" | "closed" | "all"; limit?: number } = {},
): Promise<Issue[]> {
const state = opts.state ?? "open";
const limit = opts.limit ?? 20;
return client.get<Issue[]>(`/repos/${owner}/${repo}/issues?type=issues&state=${state}&limit=${limit}`);
}
/** Get a single issue */
export async function getIssue(client: GiteaClient, owner: string, repo: string, index: number): Promise<Issue> {
return client.get<Issue>(`/repos/${owner}/${repo}/issues/${index}`);
}
/** Get comments on an issue */
export async function getIssueComments(
client: GiteaClient,
owner: string,
repo: string,
index: number,
): Promise<Comment[]> {
return client.get<Comment[]>(`/repos/${owner}/${repo}/issues/${index}/comments`);
}
/** Create a comment on an issue */
export async function createIssueComment(
client: GiteaClient,
owner: string,
repo: string,
index: number,
body: string,
): Promise<Comment> {
return client.post<Comment>(`/repos/${owner}/${repo}/issues/${index}/comments`, { body });
}
/** Create a new issue */
export async function createIssue(
client: GiteaClient,
owner: string,
repo: string,
opts: { title: string; body?: string; labels?: number[] },
): Promise<Issue> {
return client.post<Issue>(`/repos/${owner}/${repo}/issues`, {
title: opts.title,
body: opts.body ?? "",
labels: opts.labels ?? [],
});
}

69
src/pulls.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* Pull request operations
*/
import type { GiteaClient } from "./client.js";
export interface PullRequest {
id: number;
number: number;
title: string;
body: string;
state: string;
merged: boolean;
merged_at: string | null;
user: { login: string };
head: { label: string; ref: string; sha: string };
base: { label: string; ref: string };
created_at: string;
html_url: string;
}
/** List pull requests */
export async function listPullRequests(
client: GiteaClient,
owner: string,
repo: string,
opts: { state?: "open" | "closed" | "all" } = {},
): Promise<PullRequest[]> {
const state = opts.state ?? "open";
return client.get<PullRequest[]>(`/repos/${owner}/${repo}/pulls?state=${state}`);
}
/** Get a single pull request */
export async function getPullRequest(
client: GiteaClient,
owner: string,
repo: string,
index: number,
): Promise<PullRequest> {
return client.get<PullRequest>(`/repos/${owner}/${repo}/pulls/${index}`);
}
/** Create a pull request */
export async function createPullRequest(
client: GiteaClient,
owner: string,
repo: string,
opts: { title: string; head: string; base: string; body?: string },
): Promise<PullRequest> {
return client.post<PullRequest>(`/repos/${owner}/${repo}/pulls`, {
title: opts.title,
head: opts.head,
base: opts.base,
body: opts.body ?? "",
});
}
/** Merge a pull request */
export async function mergePullRequest(
client: GiteaClient,
owner: string,
repo: string,
index: number,
opts: { method?: "merge" | "rebase" | "squash" } = {},
): Promise<unknown> {
return client.post(`/repos/${owner}/${repo}/pulls/${index}/merge`, {
Do: opts.method ?? "merge",
});
}

71
src/repos.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* Repository operations
*/
import type { GiteaClient } from "./client.js";
export interface Repo {
id: number;
name: string;
full_name: string;
private: boolean;
archived: boolean;
fork: boolean;
mirror: boolean;
clone_url: string;
html_url: string;
owner: { login: string };
stars_count: number;
updated_at: string;
description: string;
default_branch: string;
}
/** List repos for authenticated user or a specific owner */
export async function listRepos(
client: GiteaClient,
opts: { owner?: string; limit?: number; archived?: string } = {},
): Promise<Repo[]> {
const limit = opts.limit ?? 50;
if (opts.owner) {
return client.get<Repo[]>(`/repos/search?q=&owner=${opts.owner}&limit=${limit}`).then((r: any) => r.data ?? r);
}
return client.get<Repo[]>(`/user/repos?limit=${limit}`);
}
/** Get a single repository */
export async function getRepo(client: GiteaClient, owner: string, repo: string): Promise<Repo> {
return client.get<Repo>(`/repos/${owner}/${repo}`);
}
/** Create a repository */
export async function createRepo(
client: GiteaClient,
opts: { name: string; private?: boolean; description?: string; autoInit?: boolean; defaultBranch?: string },
): Promise<Repo> {
return client.post<Repo>("/user/repos", {
name: opts.name,
private: opts.private ?? false,
description: opts.description ?? "",
auto_init: opts.autoInit ?? true,
default_branch: opts.defaultBranch ?? "main",
});
}
/** Ensure a repo exists — returns it if it does, creates it if not */
export async function ensureRepo(
client: GiteaClient,
owner: string,
name: string,
opts: { private?: boolean } = {},
): Promise<{ repo: Repo; created: boolean }> {
try {
const repo = await getRepo(client, owner, name);
return { repo, created: false };
} catch {
const repo = await createRepo(client, { name, private: opts.private, autoInit: false });
return { repo, created: true };
}
}

63
src/webhooks.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* Webhook operations
*/
import type { GiteaClient } from "./client.js";
export interface Webhook {
id: number;
type: string;
url: string;
active: boolean;
events: string[];
created_at: string;
}
/** List webhooks on a repo */
export async function listWebhooks(
client: GiteaClient,
owner: string,
repo: string,
): Promise<Webhook[]> {
return client.get<Webhook[]>(`/repos/${owner}/${repo}/hooks`);
}
/** Create a webhook on a repo */
export async function createWebhook(
client: GiteaClient,
owner: string,
repo: string,
opts: {
url: string;
contentType?: "json" | "form";
events?: string[];
active?: boolean;
token?: string;
},
): Promise<Webhook> {
const config: Record<string, string> = {
url: opts.url,
content_type: opts.contentType ?? "json",
};
// Use authorization header token if provided (preferred over HMAC secret)
if (opts.token) {
config.authorization = `Bearer ${opts.token}`;
}
return client.post<Webhook>(`/repos/${owner}/${repo}/hooks`, {
type: "gitea",
config,
events: opts.events ?? ["issues", "issue_comment", "pull_request", "push"],
active: opts.active ?? true,
});
}
/** Delete a webhook */
export async function deleteWebhook(
client: GiteaClient,
owner: string,
repo: string,
hookId: number,
): Promise<void> {
await client.delete(`/repos/${owner}/${repo}/hooks/${hookId}`);
}