From 25e49db15561aa9dd1b699da9c1822cf3c9cdbfd Mon Sep 17 00:00:00 2001 From: pi-bot-01 Date: Fri, 13 Mar 2026 14:49:55 -0700 Subject: [PATCH] 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. --- .gitignore | 3 + README.md | 70 ++++++++ SKILL.md | 84 +++++++++ TOOL.md | 222 +++++++++++++++++++++++ cli.ts | 187 +++++++++++++++++++ package.json | 16 ++ pi-extension/index.ts | 35 ++++ pi-extension/package.json | 9 + pi-extension/tools/read-tools.ts | 233 ++++++++++++++++++++++++ pi-extension/tools/write-tools.ts | 247 ++++++++++++++++++++++++++ pi-extension/webhook/server.ts | 286 ++++++++++++++++++++++++++++++ src/actions.ts | 86 +++++++++ src/client.ts | 125 +++++++++++++ src/files.ts | 91 ++++++++++ src/index.ts | 13 ++ src/issues.ts | 78 ++++++++ src/pulls.ts | 69 +++++++ src/repos.ts | 71 ++++++++ src/webhooks.ts | 63 +++++++ 19 files changed, 1988 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 TOOL.md create mode 100644 cli.ts create mode 100644 package.json create mode 100644 pi-extension/index.ts create mode 100644 pi-extension/package.json create mode 100644 pi-extension/tools/read-tools.ts create mode 100644 pi-extension/tools/write-tools.ts create mode 100644 pi-extension/webhook/server.ts create mode 100644 src/actions.ts create mode 100644 src/client.ts create mode 100644 src/files.ts create mode 100644 src/index.ts create mode 100644 src/issues.ts create mode 100644 src/pulls.ts create mode 100644 src/repos.ts create mode 100644 src/webhooks.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e9eee0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.js.map diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b4285a --- /dev/null +++ b/README.md @@ -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 `/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 ` header. No HMAC secrets. + +Webhook endpoint optionally validates with `Authorization: Bearer `. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..4ffa301 --- /dev/null +++ b/SKILL.md @@ -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. diff --git a/TOOL.md b/TOOL.md new file mode 100644 index 0000000..4b59073 --- /dev/null +++ b/TOOL.md @@ -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 ` header. + +Webhook endpoint uses `Authorization: Bearer ` 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`) | diff --git a/cli.ts b/cli.ts new file mode 100644 index 0000000..fc49142 --- /dev/null +++ b/cli.ts @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * Gitea CLI — standalone command-line interface + * + * Usage: gitea [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 } { + const positional: string[] = []; + const flags: Record = {}; + 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 Promise> = { + 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 [--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 [--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 "); 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 [--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); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..f548ad9 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pi-extension/index.ts b/pi-extension/index.ts new file mode 100644 index 0000000..d87132a --- /dev/null +++ b/pi-extension/index.ts @@ -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(); + }); +} diff --git a/pi-extension/package.json b/pi-extension/package.json new file mode 100644 index 0000000..5504b3f --- /dev/null +++ b/pi-extension/package.json @@ -0,0 +1,9 @@ +{ + "name": "pi-gitea", + "private": true, + "version": "1.0.0", + "type": "module", + "pi": { + "extensions": ["./index.ts"] + } +} diff --git a/pi-extension/tools/read-tools.ts b/pi-extension/tools/read-tools.ts new file mode 100644 index 0000000..7654b12 --- /dev/null +++ b/pi-extension/tools/read-tools.ts @@ -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 }, + }; + }, + }); +} diff --git a/pi-extension/tools/write-tools.ts b/pi-extension/tools/write-tools.ts new file mode 100644 index 0000000..9a83aca --- /dev/null +++ b/pi-extension/tools/write-tools.ts @@ -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 }, + }; + }, + }); +} diff --git a/pi-extension/webhook/server.ts b/pi-extension/webhook/server.ts new file mode 100644 index 0000000..bea2e8a --- /dev/null +++ b/pi-extension/webhook/server.ts @@ -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); +} diff --git a/src/actions.ts b/src/actions.ts new file mode 100644 index 0000000..38dc710 --- /dev/null +++ b/src/actions.ts @@ -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("") }; +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..7531473 --- /dev/null +++ b/src/client.ts @@ -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"; + } +} diff --git a/src/files.ts b/src/files.ts new file mode 100644 index 0000000..61d7fe2 --- /dev/null +++ b/src/files.ts @@ -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`); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4d5d25b --- /dev/null +++ b/src/index.ts @@ -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"; diff --git a/src/issues.ts b/src/issues.ts new file mode 100644 index 0000000..d869af6 --- /dev/null +++ b/src/issues.ts @@ -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 ?? [], + }); +} diff --git a/src/pulls.ts b/src/pulls.ts new file mode 100644 index 0000000..2354173 --- /dev/null +++ b/src/pulls.ts @@ -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", + }); +} diff --git a/src/repos.ts b/src/repos.ts new file mode 100644 index 0000000..f2c554b --- /dev/null +++ b/src/repos.ts @@ -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 }; + } +} diff --git a/src/webhooks.ts b/src/webhooks.ts new file mode 100644 index 0000000..0aaab58 --- /dev/null +++ b/src/webhooks.ts @@ -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}`); +}