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

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