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:
86
src/actions.ts
Normal file
86
src/actions.ts
Normal 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
125
src/client.ts
Normal 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
91
src/files.ts
Normal 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
13
src/index.ts
Normal 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
78
src/issues.ts
Normal 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
69
src/pulls.ts
Normal 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
71
src/repos.ts
Normal 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
63
src/webhooks.ts
Normal 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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user