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:
35
pi-extension/index.ts
Normal file
35
pi-extension/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* pi-gitea Extension — entry point
|
||||
*
|
||||
* Registers Gitea tools (read + write) and optional webhook server.
|
||||
*/
|
||||
|
||||
import registerReadTools from "./tools/read-tools.js";
|
||||
import registerWriteTools from "./tools/write-tools.js";
|
||||
import { startWebhookServer, stopWebhookServer, startPolling, stopPolling, setSendMessage } from "./webhook/server.js";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
registerReadTools(pi);
|
||||
registerWriteTools(pi);
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
console.log("[pi-gitea] Session started");
|
||||
|
||||
const sendMessageFn = ctx.sendUserMessage || ((_msg: string) => Promise.resolve());
|
||||
setSendMessage(sendMessageFn);
|
||||
|
||||
try {
|
||||
await startWebhookServer(pi);
|
||||
startPolling(pi);
|
||||
} catch (err) {
|
||||
console.error("[pi-gitea] Failed to start webhook server:", err);
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
console.log("[pi-gitea] Session shutting down");
|
||||
await stopWebhookServer();
|
||||
stopPolling();
|
||||
});
|
||||
}
|
||||
9
pi-extension/package.json
Normal file
9
pi-extension/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "pi-gitea",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"pi": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
233
pi-extension/tools/read-tools.ts
Normal file
233
pi-extension/tools/read-tools.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Read-only tools — list/get operations
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { GiteaClient } from "../../src/client.js";
|
||||
import * as repos from "../../src/repos.js";
|
||||
import * as issues from "../../src/issues.js";
|
||||
import * as pulls from "../../src/pulls.js";
|
||||
import * as actions from "../../src/actions.js";
|
||||
|
||||
const client = new GiteaClient();
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// ── Repositories ──
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_list_repos",
|
||||
label: "Gitea: List Repositories",
|
||||
description:
|
||||
"List repositories. Defaults to repositories accessible to the authenticated user (you). Can specify an owner to list their public repositories.",
|
||||
parameters: Type.Object({
|
||||
owner: Type.Optional(Type.String({ description: "Owner to list repos for. Omit for your own repos." })),
|
||||
limit: Type.Optional(Type.Number({ description: "Max results (default: 50)" })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const result = await repos.listRepos(client, { owner: params.owner, limit: params.limit });
|
||||
const lines = result.map((r) => {
|
||||
const vis = r.private ? "🔒" : "🌐";
|
||||
const updated = r.updated_at?.split("T")[0] ?? "unknown";
|
||||
return `${vis} ${r.full_name} — ⭐ ${r.stars_count ?? 0} | Updated: ${updated}`;
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") || "No repositories found." }],
|
||||
details: { repos: result, count: result.length },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── Issues ──
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_list_issues",
|
||||
label: "Gitea: List Issues",
|
||||
description: "List issues for a repository.",
|
||||
parameters: Type.Object({
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
state: Type.Optional(
|
||||
Type.Union([Type.Literal("open"), Type.Literal("closed"), Type.Literal("all")], {
|
||||
description: "Issue state (default: open)",
|
||||
}),
|
||||
),
|
||||
limit: Type.Optional(Type.Number({ description: "Max results (default: 20)" })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
const result = await issues.listIssues(client, owner, repo, {
|
||||
state: params.state as "open" | "closed" | "all",
|
||||
limit: params.limit,
|
||||
});
|
||||
const lines = result.map((i) => `#${i.number} [${i.state}] ${i.title} (@${i.user?.login ?? "?"})`);
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") || "No issues found." }],
|
||||
details: { issues: result },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_get_issue",
|
||||
label: "Gitea: Get Issue",
|
||||
description: "Get an issue by number, including all comments.",
|
||||
parameters: Type.Object({
|
||||
index: Type.Number({ description: "Issue number" }),
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
const [issue, comments] = await Promise.all([
|
||||
issues.getIssue(client, owner, repo, params.index),
|
||||
issues.getIssueComments(client, owner, repo, params.index),
|
||||
]);
|
||||
|
||||
let text = `# Issue #${issue.number}: ${issue.title}\n`;
|
||||
text += `State: ${issue.state} | Author: @${issue.user?.login ?? "?"} | Created: ${issue.created_at}\n`;
|
||||
if (issue.labels?.length) text += `Labels: ${issue.labels.map((l) => l.name).join(", ")}\n`;
|
||||
text += `\n${issue.body ?? "(no body)"}\n`;
|
||||
|
||||
if (comments.length > 0) {
|
||||
text += `\n--- ${comments.length} comment(s) ---\n`;
|
||||
for (const c of comments) {
|
||||
text += `\n@${c.user?.login ?? "?"} (${c.created_at}):\n${c.body}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { issue, comments },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_create_issue_comment",
|
||||
label: "Gitea: Comment on Issue",
|
||||
description: "Post a comment on an issue or pull request.",
|
||||
parameters: Type.Object({
|
||||
index: Type.Number({ description: "Issue/PR number" }),
|
||||
body: Type.String({ description: "Comment body (Markdown)" }),
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
const comment = await issues.createIssueComment(client, owner, repo, params.index, params.body);
|
||||
return {
|
||||
content: [{ type: "text", text: `Comment posted: ${comment.html_url}` }],
|
||||
details: { comment },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── Pull Requests ──
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_list_prs",
|
||||
label: "Gitea: List PRs",
|
||||
description: "List pull requests for a repository.",
|
||||
parameters: Type.Object({
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
state: Type.Optional(
|
||||
Type.Union([Type.Literal("open"), Type.Literal("closed"), Type.Literal("all")], {
|
||||
description: "PR state (default: open)",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
const result = await pulls.listPullRequests(client, owner, repo, { state: params.state as any });
|
||||
const lines = result.map(
|
||||
(p) => `#${p.number} [${p.state}] ${p.title} (@${p.user?.login ?? "?"}) ${p.head?.label ?? ""} → ${p.base?.label ?? ""}`,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") || "No PRs found." }],
|
||||
details: { prs: result },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_get_pr",
|
||||
label: "Gitea: Get PR",
|
||||
description: "Get a pull request by number, including comments.",
|
||||
parameters: Type.Object({
|
||||
index: Type.Number({ description: "PR number" }),
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
const [pr, comments] = await Promise.all([
|
||||
pulls.getPullRequest(client, owner, repo, params.index),
|
||||
issues.getIssueComments(client, owner, repo, params.index),
|
||||
]);
|
||||
|
||||
let text = `# PR #${pr.number}: ${pr.title}\n`;
|
||||
text += `State: ${pr.state} | Author: @${pr.user?.login ?? "?"} | Created: ${pr.created_at}\n`;
|
||||
text += `Base: ${pr.base?.label ?? "?"} ← Head: ${pr.head?.label ?? "?"}\n`;
|
||||
if (pr.merged) text += `Merged: ${pr.merged_at}\n`;
|
||||
text += `\n${pr.body ?? "(no body)"}\n`;
|
||||
|
||||
if (comments.length > 0) {
|
||||
text += `\n--- ${comments.length} comment(s) ---\n`;
|
||||
for (const c of comments) {
|
||||
text += `\n@${c.user?.login ?? "?"} (${c.created_at}):\n${c.body}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { pr, comments },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── Actions / CI ──
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_list_runs",
|
||||
label: "Gitea: List Runs",
|
||||
description: "List recent workflow runs for a repository.",
|
||||
parameters: Type.Object({
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
limit: Type.Optional(Type.Number({ description: "Max results (default: 10)" })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
const { runs, total } = await actions.listRuns(client, owner, repo, { limit: params.limit });
|
||||
const lines = runs.map(
|
||||
(r) =>
|
||||
`#${r.run_number} [${r.id}] ${r.conclusion ?? r.status} — ${r.display_title} (${r.head_branch} / ${r.event}) ${r.completed_at ?? r.started_at}`,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") || "No runs found." }],
|
||||
details: { runs, total },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_get_run_logs",
|
||||
label: "Gitea: Get Run Logs",
|
||||
description: "Get full log output for a workflow run. Fetches all jobs then downloads each job's logs.",
|
||||
parameters: Type.Object({
|
||||
run_id: Type.Number({ description: "Workflow run ID (the numeric id, not run_number)" }),
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
const { jobs, logs } = await actions.getRunLogs(client, owner, repo, params.run_id);
|
||||
return {
|
||||
content: [{ type: "text", text: logs }],
|
||||
details: { runId: params.run_id, jobs },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
247
pi-extension/tools/write-tools.ts
Normal file
247
pi-extension/tools/write-tools.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Write tools — create/update operations
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { GiteaClient } from "../../src/client.js";
|
||||
import * as repos from "../../src/repos.js";
|
||||
import * as issues from "../../src/issues.js";
|
||||
import * as pulls from "../../src/pulls.js";
|
||||
import * as webhooks from "../../src/webhooks.js";
|
||||
import * as files from "../../src/files.js";
|
||||
|
||||
const client = new GiteaClient();
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// ── Branches ──
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_create_branch",
|
||||
label: "Gitea: Create Branch",
|
||||
description: "Create a new branch from an existing ref.",
|
||||
parameters: Type.Object({
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
branch: Type.String({ description: "Name for the new branch" }),
|
||||
ref: Type.Optional(Type.String({ description: "Source branch (default: main)" })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
const branch = await files.createBranch(client, owner, repo, {
|
||||
name: params.branch,
|
||||
oldRef: params.ref,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `Branch created: ${branch.name}` }],
|
||||
details: { branch },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── Files ──
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_get_file_content",
|
||||
label: "Gitea: Get File Content",
|
||||
description: "Get file content from a repository at a specific path.",
|
||||
parameters: Type.Object({
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
path: Type.String({ description: "File path (e.g., 'src/index.ts')" }),
|
||||
ref: Type.Optional(Type.String({ description: "Commit SHA or branch (default: default branch)" })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
const file = await files.getFileContent(client, owner, repo, params.path, { ref: params.ref });
|
||||
// Decode base64 content
|
||||
const decoded = file.encoding === "base64" ? Buffer.from(file.content, "base64").toString("utf-8") : file.content;
|
||||
return {
|
||||
content: [{ type: "text", text: `File: ${params.path} (ref: ${params.ref ?? "HEAD"}, sha: ${file.sha})\n\n${decoded}` }],
|
||||
details: { path: params.path, ref: params.ref, sha: file.sha, encoding: file.encoding },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_update_file",
|
||||
label: "Gitea: Update File",
|
||||
description: "Update or create a file in a repository. Requires the file's current SHA for updates.",
|
||||
parameters: Type.Object({
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
path: Type.String({ description: "File path" }),
|
||||
content: Type.String({ description: "File content" }),
|
||||
message: Type.String({ description: "Commit message" }),
|
||||
branch: Type.Optional(Type.String({ description: "Target branch (default: main)" })),
|
||||
sha: Type.Optional(Type.String({ description: "Current file SHA (required for updates)" })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
const result = await files.updateFile(client, owner, repo, params.path, {
|
||||
content: params.content,
|
||||
message: params.message,
|
||||
branch: params.branch,
|
||||
sha: params.sha,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `File updated: ${params.path}` }],
|
||||
details: { commit: result.commit, content: result.content },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── Repos ──
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_create_repo",
|
||||
label: "Gitea: Create Repository",
|
||||
description: "Create a new repository for the authenticated user.",
|
||||
parameters: Type.Object({
|
||||
name: Type.String({ description: "Repository name" }),
|
||||
private: Type.Optional(Type.Boolean({ description: "Private repo (default: false)" })),
|
||||
description: Type.Optional(Type.String({ description: "Repository description" })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const repo = await repos.createRepo(client, {
|
||||
name: params.name,
|
||||
private: params.private,
|
||||
description: params.description,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `✅ Created: ${repo.html_url}` }],
|
||||
details: { repo },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_ensure_repo",
|
||||
label: "Gitea: Ensure Repository",
|
||||
description: "Get a repository if it exists, create it if not. Returns clone URL.",
|
||||
parameters: Type.Object({
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
name: Type.String({ description: "Repository name" }),
|
||||
private: Type.Optional(Type.Boolean({ description: "Private if creating (default: false)" })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const owner = params.owner ?? client.defaultOwner;
|
||||
const { repo, created } = await repos.ensureRepo(client, owner, params.name, {
|
||||
private: params.private,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `${created ? "Created" : "Exists"}: ${repo.clone_url}` }],
|
||||
details: { repo, created },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── Issues ──
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_create_issue",
|
||||
label: "Gitea: Create Issue",
|
||||
description: "Create a new issue in a repository.",
|
||||
parameters: Type.Object({
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
title: Type.String({ description: "Issue title" }),
|
||||
body: Type.Optional(Type.String({ description: "Issue body (Markdown)" })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
const issue = await issues.createIssue(client, owner, repo, {
|
||||
title: params.title,
|
||||
body: params.body,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `✅ Issue #${issue.number}: ${issue.html_url}` }],
|
||||
details: { issue },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── Pull Requests ──
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_create_pr",
|
||||
label: "Gitea: Create PR",
|
||||
description: "Create a pull request from a source branch to a target branch.",
|
||||
parameters: Type.Object({
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
title: Type.String({ description: "PR title" }),
|
||||
head: Type.String({ description: "Source branch" }),
|
||||
base: Type.String({ description: "Target branch" }),
|
||||
body: Type.Optional(Type.String({ description: "PR description" })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
const pr = await pulls.createPullRequest(client, owner, repo, {
|
||||
title: params.title,
|
||||
head: params.head,
|
||||
base: params.base,
|
||||
body: params.body,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `PR created: ${pr.html_url}` }],
|
||||
details: { pr },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_merge_pr",
|
||||
label: "Gitea: Merge PR",
|
||||
description: "Merge a pull request into its base branch.",
|
||||
parameters: Type.Object({
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
index: Type.Number({ description: "PR number" }),
|
||||
merge_method: Type.Optional(
|
||||
Type.Union([Type.Literal("merge"), Type.Literal("rebase"), Type.Literal("squash")], {
|
||||
description: "Merge method (default: merge)",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
await pulls.mergePullRequest(client, owner, repo, params.index, {
|
||||
method: params.merge_method as any,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `PR #${params.index} merged.` }],
|
||||
details: { index: params.index },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── Webhooks ──
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_create_webhook",
|
||||
label: "Gitea: Create Webhook",
|
||||
description: "Create a webhook on a repository.",
|
||||
parameters: Type.Object({
|
||||
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
|
||||
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
|
||||
url: Type.String({ description: "URL where Gitea will send webhooks" }),
|
||||
token: Type.Optional(Type.String({ description: "Bearer token for webhook auth" })),
|
||||
events: Type.Optional(
|
||||
Type.Array(Type.String(), { description: "Events to listen for (default: issues, issue_comment, pull_request, push)" }),
|
||||
),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const { owner, repo } = client.resolve(params.owner, params.repo);
|
||||
const hook = await webhooks.createWebhook(client, owner, repo, {
|
||||
url: params.url,
|
||||
token: params.token,
|
||||
events: params.events,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `Webhook created: ID ${hook.id}` }],
|
||||
details: { webhook: hook },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
286
pi-extension/webhook/server.ts
Normal file
286
pi-extension/webhook/server.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Webhook server — receives Gitea events via HTTP
|
||||
*
|
||||
* Auth: Bearer token validation (PI_WEBHOOK_TOKEN).
|
||||
* No HMAC/secret — consistent with token-based auth strategy.
|
||||
*/
|
||||
|
||||
import type { Server, IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { GiteaClient } from "../../src/client.js";
|
||||
|
||||
const WEBHOOK_HOST = process.env.PI_WEBHOOK_HOST ?? "0.0.0.0";
|
||||
const WEBHOOK_PORT = parseInt(process.env.PI_WEBHOOK_PORT ?? "3000", 10);
|
||||
const WEBHOOK_TOKEN = process.env.PI_WEBHOOK_TOKEN ?? "";
|
||||
const WEBHOOK_URL = process.env.PI_WEBHOOK_URL ?? "";
|
||||
const POLL_INTERVAL = parseInt(process.env.PI_BOT_POLL_INTERVAL ?? "300", 10);
|
||||
|
||||
let server: Server | null = null;
|
||||
let trackedRepos: Map<string, { webhookId: number; addedAt: number }> = new Map();
|
||||
let processingQueue: Array<{ event: any; timestamp: number }> = [];
|
||||
let maxQueueDepth = 50;
|
||||
let isProcessing = false;
|
||||
let pollTimer: NodeJS.Timeout | null = null;
|
||||
let sendMessage: ((message: string) => Promise<void>) | null = null;
|
||||
|
||||
export function setSendMessage(fn: (message: string) => Promise<void>) {
|
||||
sendMessage = fn;
|
||||
}
|
||||
|
||||
/** Validate bearer token on incoming webhook request */
|
||||
function validateToken(req: IncomingMessage): boolean {
|
||||
if (!WEBHOOK_TOKEN) return true; // No token configured = open (localhost only)
|
||||
const auth = req.headers["authorization"];
|
||||
if (!auth) return false;
|
||||
return auth === `Bearer ${WEBHOOK_TOKEN}`;
|
||||
}
|
||||
|
||||
/** Format a Gitea event as a prompt for the LLM */
|
||||
function formatEventPrompt(event: any): string {
|
||||
const action = event.action;
|
||||
const repo = event.repository?.full_name || "unknown";
|
||||
|
||||
let prompt = `New Gitea event on ${repo}:\n\n`;
|
||||
prompt += `**Action**: ${action}\n\n`;
|
||||
|
||||
if (event.issue) {
|
||||
const issue = event.issue;
|
||||
prompt += `**Issue #${issue.number}: ${issue.title}**\n`;
|
||||
prompt += `**Author**: @${issue.user?.login || "unknown"}\n`;
|
||||
prompt += `**Labels**: ${issue.labels?.map((l: any) => l.name).join(", ") || "none"}\n`;
|
||||
prompt += `**Body**:\n${issue.body || "(no body)"}\n\n`;
|
||||
}
|
||||
|
||||
if (event.pull_request) {
|
||||
const pr = event.pull_request;
|
||||
prompt += `**PR #${pr.number}: ${pr.title}**\n`;
|
||||
prompt += `**Author**: @${pr.user?.login || "unknown"}\n`;
|
||||
prompt += `**Base**: ${pr.base?.label} ← **Head**: ${pr.head?.label}\n`;
|
||||
prompt += `**Body**:\n${pr.body || "(no body)"}\n\n`;
|
||||
}
|
||||
|
||||
if (event.comment) {
|
||||
const comment = event.comment;
|
||||
const targetNumber = event.issue?.number || event.pull_request?.number;
|
||||
prompt += `**Comment on #${targetNumber}**\n`;
|
||||
prompt += `**Author**: @${comment.user?.login || "unknown"}\n`;
|
||||
prompt += `**Body**:\n${comment.body || "(no body)"}\n\n`;
|
||||
}
|
||||
|
||||
if (event.pusher) {
|
||||
prompt += `**Pusher**: @${event.pusher.name}\n`;
|
||||
prompt += `**Commits**: ${event.commits?.length || 0}\n\n`;
|
||||
}
|
||||
|
||||
prompt += `---\n\n`;
|
||||
prompt += `Please analyze this event and decide how to respond. You can:\n`;
|
||||
prompt += `1. Add helpful comments to issues/PRs\n`;
|
||||
prompt += `2. Suggest code fixes or improvements\n`;
|
||||
prompt += `3. Create branches and PRs to fix issues\n`;
|
||||
prompt += `4. Update files directly (if direct_push is enabled)\n`;
|
||||
prompt += `5. Ask for clarification if needed\n\n`;
|
||||
prompt += `Use the available Gitea tools to interact with the repository.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/** Process the event queue */
|
||||
async function processQueue() {
|
||||
if (isProcessing || processingQueue.length === 0) return;
|
||||
|
||||
isProcessing = true;
|
||||
|
||||
while (processingQueue.length > 0) {
|
||||
const { event } = processingQueue.shift()!;
|
||||
const repoName = event.repository?.full_name || "unknown";
|
||||
|
||||
console.log(`[gitea-webhook] Processing event: ${event.action} on ${repoName}`);
|
||||
|
||||
if (sendMessage) {
|
||||
try {
|
||||
const prompt = formatEventPrompt(event);
|
||||
await sendMessage(prompt);
|
||||
console.log(`[gitea-webhook] Event sent to LLM: ${event.action} on ${repoName}`);
|
||||
} catch (err) {
|
||||
console.error(`[gitea-webhook] Failed to send event to LLM:`, err);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[gitea-webhook] No sendMessage function available, skipping event`);
|
||||
}
|
||||
}
|
||||
|
||||
isProcessing = false;
|
||||
}
|
||||
|
||||
/** Start webhook server */
|
||||
export function startWebhookServer(_pi: ExtensionAPI) {
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
try {
|
||||
const http = await import("node:http");
|
||||
|
||||
server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const url = req.url || "";
|
||||
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /health
|
||||
if (url === "/health" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: "ok",
|
||||
uptime: process.uptime(),
|
||||
tracked_repos: trackedRepos.size,
|
||||
queue_depth: processingQueue.length,
|
||||
is_processing: isProcessing,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /hooks/gitea
|
||||
if (url === "/hooks/gitea" && req.method === "POST") {
|
||||
// Validate token
|
||||
if (!validateToken(req)) {
|
||||
res.writeHead(401, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Unauthorized" }));
|
||||
console.error("[gitea-webhook] Token validation failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read body
|
||||
let body = "";
|
||||
for await (const chunk of req) {
|
||||
body += chunk.toString();
|
||||
}
|
||||
|
||||
// Parse event
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(body);
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue event
|
||||
processingQueue.push({ event, timestamp: Date.now() });
|
||||
|
||||
if (processingQueue.length > maxQueueDepth) {
|
||||
const dropped = processingQueue.shift();
|
||||
if (dropped) {
|
||||
console.warn(`[gitea-webhook] Queue full, dropping oldest event`);
|
||||
}
|
||||
}
|
||||
|
||||
void processQueue();
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ received: true, event: event.action }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
server.listen(WEBHOOK_PORT, WEBHOOK_HOST, () => {
|
||||
console.log(`[gitea-webhook] Server listening on ${WEBHOOK_HOST}:${WEBHOOK_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
server.on("error", reject);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop webhook server */
|
||||
export async function stopWebhookServer() {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (server) {
|
||||
server.close(() => {
|
||||
console.log("[gitea-webhook] Server stopped");
|
||||
server = null;
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Poll for new repos and register webhooks */
|
||||
export function startPolling(_pi: ExtensionAPI) {
|
||||
void fetchUserRepos();
|
||||
pollTimer = setInterval(() => void fetchUserRepos(), POLL_INTERVAL * 1000);
|
||||
console.log(`[gitea-polling] Polling started (interval: ${POLL_INTERVAL}s)`);
|
||||
}
|
||||
|
||||
export function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserRepos() {
|
||||
try {
|
||||
const client = new GiteaClient();
|
||||
if (!WEBHOOK_URL) {
|
||||
console.warn("[gitea-polling] PI_WEBHOOK_URL not set, skipping webhook registration");
|
||||
return;
|
||||
}
|
||||
|
||||
const repos = await client.get<any[]>("/user/repos?limit=100");
|
||||
|
||||
const newRepos: any[] = [];
|
||||
for (const repo of repos) {
|
||||
if (!trackedRepos.has(repo.full_name)) {
|
||||
newRepos.push(repo);
|
||||
}
|
||||
}
|
||||
|
||||
if (newRepos.length > 0) {
|
||||
console.log(`[gitea-polling] Found ${newRepos.length} new repos, registering webhooks...`);
|
||||
|
||||
for (const repo of newRepos) {
|
||||
try {
|
||||
const webhook = await client.post<any>(`/repos/${repo.full_name}/hooks`, {
|
||||
type: "gitea",
|
||||
config: {
|
||||
url: `${WEBHOOK_URL}/hooks/gitea`,
|
||||
content_type: "json",
|
||||
...(WEBHOOK_TOKEN ? { authorization: `Bearer ${WEBHOOK_TOKEN}` } : {}),
|
||||
},
|
||||
events: ["issues", "issue_comment", "pull_request", "push"],
|
||||
active: true,
|
||||
});
|
||||
|
||||
trackedRepos.set(repo.full_name, { webhookId: webhook.id, addedAt: Date.now() });
|
||||
console.log(`[gitea-polling] Webhook created for ${repo.full_name} (ID: ${webhook.id})`);
|
||||
} catch (err) {
|
||||
console.error(`[gitea-polling] Error creating webhook for ${repo.full_name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[gitea-polling] Current repos: ${trackedRepos.size}`);
|
||||
} catch (err) {
|
||||
console.error("[gitea-polling] Error fetching user repos:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function getTrackedRepos() {
|
||||
return new Map(trackedRepos);
|
||||
}
|
||||
Reference in New Issue
Block a user