941 lines
31 KiB
TypeScript
941 lines
31 KiB
TypeScript
/**
|
|
* Webhook server + notification poller
|
|
*
|
|
* Two event sources:
|
|
* 1. Webhooks (own repos with admin) — real-time, all events
|
|
* 2. Notification polling (collab repos) — @mention/assign driven
|
|
*
|
|
* @mention routing:
|
|
* - Own repos: respond to all events (configurable to mention-only)
|
|
* - Collab repos: respond only when @mentioned or assigned
|
|
* - Extracts directive text after @botname for focused LLM context
|
|
*
|
|
* Auth: Bearer token for inbound webhooks, token auth for Gitea API.
|
|
*/
|
|
|
|
import type { Server, IncomingMessage, ServerResponse } from "node:http";
|
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
import { GiteaClient, GiteaError } from "../../src/client.js";
|
|
|
|
// ── Config ───────────────────────────────────────────────────────────────────
|
|
|
|
const WEBHOOK_HOST = process.env.GITEA_WEBHOOK_HOST ?? "0.0.0.0";
|
|
const WEBHOOK_PORT = parseInt(process.env.GITEA_WEBHOOK_PORT ?? "3000", 10);
|
|
const WEBHOOK_TOKEN = process.env.GITEA_WEBHOOK_TOKEN ?? "";
|
|
const WEBHOOK_URL = process.env.GITEA_WEBHOOK_URL ?? "";
|
|
const POLL_INTERVAL = parseInt(process.env.GITEA_POLL_INTERVAL ?? "300", 10);
|
|
const NOTIF_POLL_INTERVAL = parseInt(process.env.GITEA_NOTIF_INTERVAL ?? "30", 10);
|
|
const BOT_USER = process.env.GITEA_USER ?? "";
|
|
|
|
const STATE_FILE = `${process.env.HOME ?? "/tmp"}/.pi/agent/gitea-poll-state.json`;
|
|
|
|
// ── State ────────────────────────────────────────────────────────────────────
|
|
|
|
let server: Server | null = null;
|
|
let serverOwned = false; // true if THIS process bound the port
|
|
let processingQueue: Array<{ prompt: string; timestamp: number }> = [];
|
|
const maxQueueDepth = 50;
|
|
let isProcessing = false;
|
|
let repoPollTimer: NodeJS.Timeout | null = null;
|
|
let notifPollTimer: NodeJS.Timeout | null = null;
|
|
let sendMessage: ((message: string) => Promise<void>) | null = null;
|
|
|
|
// ── Claim system ─────────────────────────────────────────────────────────────
|
|
// Multiple pi sessions may start concurrently. Only one can bind the webhook
|
|
// port. The instance that binds it becomes the initial "claim owner" — webhook
|
|
// events are delivered to its sendMessage callback.
|
|
//
|
|
// Other sessions can request ownership via POST /claim:
|
|
//
|
|
// 1. Client sends POST /claim { session_id, cwd, referral_id? }
|
|
// 2. Server responds 202 { referral_id, position, owner } — queued
|
|
// 3. Client polls POST /claim { session_id, referral_id }
|
|
// 4. Server responds:
|
|
// 202 — still pending (current owner hasn't released)
|
|
// 200 — claimed (you are the new owner)
|
|
// 401 — denied (owner explicitly rejected — reserved for future use)
|
|
// 408 — timed out (claim expired)
|
|
// 5. GET /claim — returns current owner info + pending queue
|
|
//
|
|
// The current owner releases via the /webhook:release pi command, which calls
|
|
// approvePendingClaim() in-process (no HTTP needed — it's the server process).
|
|
//
|
|
// DOS protection: max pending referrals; 503 for new clients when full.
|
|
|
|
/** Unique id for this pi process */
|
|
const SESSION_ID = `pi-${process.pid}-${Date.now().toString(36)}`;
|
|
|
|
/** Session id of whoever currently receives events */
|
|
let claimOwner: string = SESSION_ID;
|
|
/** CWD of current owner (for display) */
|
|
let claimOwnerCwd: string = process.cwd();
|
|
|
|
interface ClaimRequest {
|
|
referralId: string;
|
|
sessionId: string;
|
|
cwd: string;
|
|
status: "pending" | "approved" | "denied" | "expired";
|
|
createdAt: number;
|
|
}
|
|
|
|
const MAX_PENDING_CLAIMS = 10;
|
|
const CLAIM_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
let pendingClaims: Map<string, ClaimRequest> = new Map();
|
|
|
|
/** Notify callback — set by the extension to alert the current owner */
|
|
let onClaimRequested: ((claim: ClaimRequest) => void) | null = null;
|
|
|
|
export function setOnClaimRequested(fn: (claim: ClaimRequest) => void) {
|
|
onClaimRequested = fn;
|
|
}
|
|
|
|
function generateReferralId(): string {
|
|
return `ref-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function purgeExpiredClaims() {
|
|
const now = Date.now();
|
|
for (const [id, claim] of pendingClaims) {
|
|
if (now - claim.createdAt > CLAIM_TIMEOUT_MS) {
|
|
claim.status = "expired";
|
|
pendingClaims.delete(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called by the server owner (via /webhook:release command) to approve the
|
|
* next pending claim. Transfers ownership to the oldest pending request.
|
|
*/
|
|
export function approvePendingClaim(): { approved: ClaimRequest | null; newOwner: string } {
|
|
purgeExpiredClaims();
|
|
|
|
// Find oldest pending
|
|
let oldest: ClaimRequest | null = null;
|
|
for (const claim of pendingClaims.values()) {
|
|
if (claim.status === "pending") {
|
|
if (!oldest || claim.createdAt < oldest.createdAt) {
|
|
oldest = claim;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!oldest) {
|
|
return { approved: null, newOwner: claimOwner };
|
|
}
|
|
|
|
oldest.status = "approved";
|
|
claimOwner = oldest.sessionId;
|
|
claimOwnerCwd = oldest.cwd;
|
|
console.log(`[gitea-claim] Ownership transferred to ${oldest.sessionId} (${oldest.cwd})`);
|
|
|
|
return { approved: oldest, newOwner: claimOwner };
|
|
}
|
|
|
|
/** Get current claim status for display */
|
|
export function getClaimStatus() {
|
|
purgeExpiredClaims();
|
|
return {
|
|
owner: claimOwner,
|
|
ownerCwd: claimOwnerCwd,
|
|
sessionId: SESSION_ID,
|
|
isOwner: claimOwner === SESSION_ID,
|
|
pending: [...pendingClaims.values()].filter((c) => c.status === "pending"),
|
|
};
|
|
}
|
|
|
|
/** Handle POST /claim from another pi instance */
|
|
function handleClaimRequest(body: { session_id?: string; cwd?: string; referral_id?: string }): {
|
|
status: number;
|
|
body: Record<string, unknown>;
|
|
} {
|
|
purgeExpiredClaims();
|
|
|
|
const sessionId = body.session_id ?? "unknown";
|
|
const cwd = body.cwd ?? "unknown";
|
|
const referralId = body.referral_id;
|
|
|
|
// Polling with existing referral
|
|
if (referralId) {
|
|
const claim = pendingClaims.get(referralId);
|
|
if (!claim) {
|
|
// Unknown referral — expired or never existed
|
|
return { status: 408, body: { error: "expired", referral_id: referralId } };
|
|
}
|
|
if (claim.status === "approved") {
|
|
pendingClaims.delete(referralId);
|
|
return {
|
|
status: 200,
|
|
body: { claimed: true, referral_id: referralId, owner: claimOwner },
|
|
};
|
|
}
|
|
if (claim.status === "denied") {
|
|
pendingClaims.delete(referralId);
|
|
return { status: 401, body: { denied: true, referral_id: referralId } };
|
|
}
|
|
if (claim.status === "expired") {
|
|
pendingClaims.delete(referralId);
|
|
return { status: 408, body: { error: "expired", referral_id: referralId } };
|
|
}
|
|
// Still pending
|
|
const position = [...pendingClaims.values()]
|
|
.filter((c) => c.status === "pending" && c.createdAt <= claim.createdAt)
|
|
.length;
|
|
return {
|
|
status: 202,
|
|
body: {
|
|
referral_id: referralId,
|
|
status: "pending",
|
|
position,
|
|
owner: claimOwner,
|
|
owner_cwd: claimOwnerCwd,
|
|
},
|
|
};
|
|
}
|
|
|
|
// New claim request (no referral_id)
|
|
if (pendingClaims.size >= MAX_PENDING_CLAIMS) {
|
|
return { status: 503, body: { error: "too many pending claims" } };
|
|
}
|
|
|
|
const newReferralId = generateReferralId();
|
|
const claim: ClaimRequest = {
|
|
referralId: newReferralId,
|
|
sessionId,
|
|
cwd,
|
|
status: "pending",
|
|
createdAt: Date.now(),
|
|
};
|
|
pendingClaims.set(newReferralId, claim);
|
|
|
|
console.log(`[gitea-claim] New claim request from ${sessionId} (${cwd}), referral: ${newReferralId}`);
|
|
if (onClaimRequested) onClaimRequested(claim);
|
|
|
|
return {
|
|
status: 202,
|
|
body: {
|
|
referral_id: newReferralId,
|
|
status: "pending",
|
|
position: pendingClaims.size,
|
|
owner: claimOwner,
|
|
owner_cwd: claimOwnerCwd,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Repos where we successfully installed a webhook */
|
|
let webhookRepos: Map<string, { webhookId: number; addedAt: number }> = new Map();
|
|
|
|
/** Repos where webhook install failed (403) — notification-polled */
|
|
let collabRepos: Set<string> = new Set();
|
|
|
|
/** All known repos (avoid re-attempting webhook install) */
|
|
let knownRepos: Set<string> = new Set();
|
|
|
|
/** Per-repo config */
|
|
interface RepoConfig {
|
|
/** "all" = respond to everything, "mention" = only when @mentioned/assigned */
|
|
respondTo: "all" | "mention";
|
|
}
|
|
let repoConfigs: Map<string, RepoConfig> = new Map();
|
|
|
|
/** Last notification ID we've processed — NOT USED, Gitea reuses thread IDs */
|
|
// Tracking is done by marking notifications as read after processing.
|
|
|
|
// ── Persistence ──────────────────────────────────────────────────────────────
|
|
|
|
interface PollState {
|
|
repoConfigs: Record<string, RepoConfig>;
|
|
}
|
|
|
|
async function loadState(): Promise<PollState> {
|
|
try {
|
|
const fs = await import("node:fs/promises");
|
|
const data = await fs.readFile(STATE_FILE, "utf-8");
|
|
const parsed = JSON.parse(data);
|
|
return {
|
|
repoConfigs: parsed.repoConfigs ?? {},
|
|
};
|
|
} catch {
|
|
return { repoConfigs: {} };
|
|
}
|
|
}
|
|
|
|
async function saveState(): Promise<void> {
|
|
try {
|
|
const fs = await import("node:fs/promises");
|
|
const state: PollState = {
|
|
repoConfigs: Object.fromEntries(repoConfigs),
|
|
};
|
|
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), "utf-8");
|
|
} catch (err) {
|
|
console.error("[gitea-state] Error saving:", err instanceof Error ? err.message : err);
|
|
}
|
|
}
|
|
|
|
// ── @Mention Parsing ─────────────────────────────────────────────────────────
|
|
|
|
interface MentionInfo {
|
|
/** Whether this bot was @mentioned */
|
|
mentioned: boolean;
|
|
/** All @usernames found in the text */
|
|
allMentions: string[];
|
|
/** Text directed at this bot (after @botname, up to next @mention or end) */
|
|
directive: string | null;
|
|
/** Full original text */
|
|
fullText: string;
|
|
}
|
|
|
|
/**
|
|
* Parse @mentions from text.
|
|
* Extracts directive = text after @botname up to the next @mention or paragraph.
|
|
*/
|
|
function parseMentions(text: string, botUser: string): MentionInfo {
|
|
if (!text) return { mentioned: false, allMentions: [], directive: null, fullText: text };
|
|
|
|
// Find all @mentions (word boundary, not inside URLs/emails)
|
|
const mentionPattern = /(?:^|[\s(])@([a-zA-Z0-9_-]+)/g;
|
|
const allMentions: string[] = [];
|
|
let match;
|
|
while ((match = mentionPattern.exec(text)) !== null) {
|
|
allMentions.push(match[1]);
|
|
}
|
|
|
|
const mentioned = allMentions.some(
|
|
(m) => m.toLowerCase() === botUser.toLowerCase(),
|
|
);
|
|
|
|
let directive: string | null = null;
|
|
if (mentioned) {
|
|
// Extract text after @botname
|
|
const botPattern = new RegExp(
|
|
`@${escapeRegex(botUser)}\\s*([\\s\\S]*?)(?=@[a-zA-Z0-9_-]+|$)`,
|
|
"i",
|
|
);
|
|
const directiveMatch = text.match(botPattern);
|
|
if (directiveMatch) {
|
|
directive = directiveMatch[1].trim() || null;
|
|
}
|
|
}
|
|
|
|
return { mentioned, allMentions, directive, fullText: text };
|
|
}
|
|
|
|
function escapeRegex(s: string): string {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
// ── Prompt Formatting ────────────────────────────────────────────────────────
|
|
|
|
interface EventContext {
|
|
repo: string;
|
|
type: "issue" | "pull_request" | "issue_comment" | "push";
|
|
action: string;
|
|
number?: number;
|
|
title?: string;
|
|
author?: string;
|
|
body?: string;
|
|
labels?: string[];
|
|
/** PR-specific */
|
|
baseBranch?: string;
|
|
headBranch?: string;
|
|
/** Comment-specific: the issue/PR number being commented on */
|
|
parentNumber?: number;
|
|
/** Parsed mention info */
|
|
mention?: MentionInfo;
|
|
/** Source: "webhook" or "notification" */
|
|
source: "webhook" | "notification";
|
|
}
|
|
|
|
function formatPrompt(ctx: EventContext): string {
|
|
const { repo, type, action, mention, source } = ctx;
|
|
const via = source === "notification" ? " (via @mention)" : "";
|
|
|
|
let prompt = `New Gitea event on ${repo}${via}:\n\n`;
|
|
prompt += `**Action**: ${action}\n\n`;
|
|
|
|
if (type === "issue" || type === "pull_request") {
|
|
const kind = type === "issue" ? "Issue" : "PR";
|
|
prompt += `**${kind} #${ctx.number}: ${ctx.title}**\n`;
|
|
prompt += `**Author**: @${ctx.author || "unknown"}\n`;
|
|
if (ctx.labels?.length) prompt += `**Labels**: ${ctx.labels.join(", ")}\n`;
|
|
if (ctx.baseBranch) prompt += `**Base**: ${ctx.baseBranch} ← **Head**: ${ctx.headBranch}\n`;
|
|
prompt += `**Body**:\n${ctx.body || "(no body)"}\n\n`;
|
|
}
|
|
|
|
if (type === "issue_comment") {
|
|
prompt += `**Comment on #${ctx.parentNumber}**\n`;
|
|
prompt += `**Author**: @${ctx.author || "unknown"}\n`;
|
|
prompt += `**Body**:\n${ctx.body || "(no body)"}\n\n`;
|
|
}
|
|
|
|
if (type === "push") {
|
|
prompt += `**Pusher**: @${ctx.author}\n\n`;
|
|
}
|
|
|
|
// Add mention context
|
|
if (mention?.mentioned && mention.directive) {
|
|
prompt += `---\n\n`;
|
|
prompt += `**You were @mentioned.** The request directed at you:\n`;
|
|
prompt += `> ${mention.directive}\n\n`;
|
|
} else if (mention?.mentioned) {
|
|
prompt += `---\n\n`;
|
|
prompt += `**You were @mentioned** in this ${type === "issue_comment" ? "comment" : type}.\n\n`;
|
|
}
|
|
|
|
if (mention?.allMentions.length && mention.allMentions.length > 1) {
|
|
const others = mention.allMentions.filter(
|
|
(m) => m.toLowerCase() !== BOT_USER.toLowerCase(),
|
|
);
|
|
if (others.length > 0) {
|
|
prompt += `Other users mentioned: ${others.map((m) => "@" + m).join(", ")}\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\n`;
|
|
prompt += `5. Close issues when work is done\n`;
|
|
prompt += `6. Ask for clarification if needed\n\n`;
|
|
prompt += `Use the available Gitea tools to interact with the repository.`;
|
|
|
|
return prompt;
|
|
}
|
|
|
|
// ── Event Queue ──────────────────────────────────────────────────────────────
|
|
|
|
export function setSendMessage(fn: (message: string) => Promise<void>) {
|
|
sendMessage = fn;
|
|
}
|
|
|
|
function enqueuePrompt(prompt: string) {
|
|
processingQueue.push({ prompt, timestamp: Date.now() });
|
|
|
|
if (processingQueue.length > maxQueueDepth) {
|
|
processingQueue.shift();
|
|
console.warn(`[gitea-events] Queue full, dropped oldest event`);
|
|
}
|
|
|
|
void processQueue();
|
|
}
|
|
|
|
async function processQueue() {
|
|
if (isProcessing || processingQueue.length === 0) return;
|
|
|
|
isProcessing = true;
|
|
|
|
while (processingQueue.length > 0) {
|
|
const { prompt } = processingQueue.shift()!;
|
|
|
|
if (sendMessage) {
|
|
try {
|
|
await sendMessage(prompt);
|
|
console.log(`[gitea-events] Event delivered to LLM`);
|
|
} catch (err) {
|
|
console.error(`[gitea-events] Failed to send to LLM:`, err instanceof Error ? err.message : err);
|
|
}
|
|
} else {
|
|
console.warn(`[gitea-events] No sendMessage function, skipping`);
|
|
}
|
|
}
|
|
|
|
isProcessing = false;
|
|
}
|
|
|
|
// ── Mention Filter ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Should we process this event?
|
|
* - Own repos (webhook): always, unless config says "mention"
|
|
* - Collab repos: only if @mentioned
|
|
* - Always skip events from the bot itself
|
|
*/
|
|
function shouldProcess(repo: string, author: string, body: string, source: "webhook" | "notification"): { process: boolean; mention: MentionInfo } {
|
|
const mention = parseMentions(body, BOT_USER);
|
|
|
|
// Never process our own events
|
|
if (author.toLowerCase() === BOT_USER.toLowerCase()) {
|
|
return { process: false, mention };
|
|
}
|
|
|
|
// Notification source = already @mention filtered by Gitea
|
|
if (source === "notification") {
|
|
return { process: true, mention };
|
|
}
|
|
|
|
// Webhook source — check repo config
|
|
const config = repoConfigs.get(repo) ?? { respondTo: "all" };
|
|
if (config.respondTo === "mention") {
|
|
return { process: mention.mentioned, mention };
|
|
}
|
|
|
|
// Default for webhook repos: respond to all
|
|
return { process: true, mention };
|
|
}
|
|
|
|
// ── HTTP Webhook Server ──────────────────────────────────────────────────────
|
|
|
|
function validateToken(req: IncomingMessage): boolean {
|
|
if (!WEBHOOK_TOKEN) return true;
|
|
const auth = req.headers["authorization"];
|
|
if (!auth) return false;
|
|
return auth === `Bearer ${WEBHOOK_TOKEN}`;
|
|
}
|
|
|
|
export function startWebhookServer(_pi: ExtensionAPI) {
|
|
return new Promise<void>(async (resolve) => {
|
|
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(),
|
|
bot_user: BOT_USER,
|
|
session_id: SESSION_ID,
|
|
claim_owner: claimOwner,
|
|
claim_owner_cwd: claimOwnerCwd,
|
|
webhook_repos: webhookRepos.size,
|
|
collab_repos: collabRepos.size,
|
|
queue_depth: processingQueue.length,
|
|
is_processing: isProcessing,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// GET /claim — current owner info
|
|
if (url === "/claim" && req.method === "GET") {
|
|
purgeExpiredClaims();
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({
|
|
owner: claimOwner,
|
|
owner_cwd: claimOwnerCwd,
|
|
server_session: SESSION_ID,
|
|
pending: [...pendingClaims.values()]
|
|
.filter((c) => c.status === "pending")
|
|
.map((c) => ({
|
|
referral_id: c.referralId,
|
|
session_id: c.sessionId,
|
|
cwd: c.cwd,
|
|
age_ms: Date.now() - c.createdAt,
|
|
})),
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// POST /claim — request or poll claim
|
|
if (url === "/claim" && req.method === "POST") {
|
|
let rawBody = "";
|
|
for await (const chunk of req) rawBody += chunk.toString();
|
|
|
|
let reqBody: any;
|
|
try {
|
|
reqBody = JSON.parse(rawBody);
|
|
} catch {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
return;
|
|
}
|
|
|
|
const result = handleClaimRequest(reqBody);
|
|
res.writeHead(result.status, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(result.body));
|
|
return;
|
|
}
|
|
|
|
// POST /hooks/gitea
|
|
if (url === "/hooks/gitea" && req.method === "POST") {
|
|
if (!validateToken(req)) {
|
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
return;
|
|
}
|
|
|
|
let rawBody = "";
|
|
for await (const chunk of req) rawBody += chunk.toString();
|
|
|
|
let event: any;
|
|
try {
|
|
event = JSON.parse(rawBody);
|
|
} catch {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
return;
|
|
}
|
|
|
|
handleWebhookEvent(event);
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ received: true }));
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
res.end("Not found");
|
|
});
|
|
|
|
server.listen(WEBHOOK_PORT, WEBHOOK_HOST, () => {
|
|
serverOwned = true;
|
|
claimOwner = SESSION_ID;
|
|
claimOwnerCwd = process.cwd();
|
|
console.log(`[gitea-webhook] Server listening on ${WEBHOOK_HOST}:${WEBHOOK_PORT} (session: ${SESSION_ID})`);
|
|
resolve();
|
|
});
|
|
|
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
if (err.code === "EADDRINUSE") {
|
|
server = null;
|
|
serverOwned = false;
|
|
console.log(`[gitea-webhook] Port ${WEBHOOK_PORT} already in use — another pi instance owns the webhook server`);
|
|
console.log(`[gitea-webhook] Use /webhook:claim to request ownership, or /webhook:status to see current owner`);
|
|
resolve(); // not fatal — tools still work, just no local server
|
|
} else {
|
|
console.error(`[gitea-webhook] Server error: ${err.message}`);
|
|
resolve(); // don't crash the extension
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error(`[gitea-webhook] Failed to create server: ${err}`);
|
|
resolve();
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Process an inbound webhook event (from owned repos) */
|
|
function handleWebhookEvent(event: any) {
|
|
const repo = event.repository?.full_name || "unknown";
|
|
|
|
// Determine event type and extract body/author
|
|
let body = "";
|
|
let author = "";
|
|
let eventCtx: EventContext | null = null;
|
|
|
|
if (event.issue && !event.comment) {
|
|
// Issue opened/edited
|
|
body = event.issue.body || "";
|
|
author = event.issue.user?.login || "";
|
|
eventCtx = {
|
|
repo, type: "issue", action: event.action,
|
|
number: event.issue.number, title: event.issue.title,
|
|
author, body, labels: event.issue.labels?.map((l: any) => l.name),
|
|
source: "webhook",
|
|
};
|
|
} else if (event.comment) {
|
|
// Issue/PR comment
|
|
body = event.comment.body || "";
|
|
author = event.comment.user?.login || "";
|
|
const parentNumber = event.issue?.number || event.pull_request?.number;
|
|
eventCtx = {
|
|
repo, type: "issue_comment", action: event.action,
|
|
author, body, parentNumber,
|
|
source: "webhook",
|
|
};
|
|
} else if (event.pull_request && !event.comment) {
|
|
// PR opened/edited
|
|
body = event.pull_request.body || "";
|
|
author = event.pull_request.user?.login || "";
|
|
eventCtx = {
|
|
repo, type: "pull_request", action: event.action,
|
|
number: event.pull_request.number, title: event.pull_request.title,
|
|
author, body,
|
|
baseBranch: event.pull_request.base?.label,
|
|
headBranch: event.pull_request.head?.label,
|
|
source: "webhook",
|
|
};
|
|
} else if (event.pusher) {
|
|
// Push event — no @mention filtering
|
|
author = event.pusher?.name || "";
|
|
if (author.toLowerCase() === BOT_USER.toLowerCase()) return;
|
|
eventCtx = {
|
|
repo, type: "push", action: "push",
|
|
author, body: "",
|
|
source: "webhook",
|
|
};
|
|
}
|
|
|
|
if (!eventCtx) return;
|
|
|
|
const { process: shouldDo, mention } = shouldProcess(repo, author, body, "webhook");
|
|
if (!shouldDo) {
|
|
console.log(`[gitea-webhook] Skipped event on ${repo} (not mentioned, respondTo=mention)`);
|
|
return;
|
|
}
|
|
|
|
eventCtx.mention = mention;
|
|
const prompt = formatPrompt(eventCtx);
|
|
console.log(`[gitea-webhook] Event: ${eventCtx.type}/${eventCtx.action} on ${repo} by @${author}${mention.mentioned ? " (mentioned)" : ""}`);
|
|
enqueuePrompt(prompt);
|
|
}
|
|
|
|
export async function stopWebhookServer() {
|
|
return new Promise<void>((resolve) => {
|
|
if (server) {
|
|
server.close(() => {
|
|
console.log("[gitea-webhook] Server stopped");
|
|
server = null;
|
|
resolve();
|
|
});
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Repo Discovery ───────────────────────────────────────────────────────────
|
|
|
|
async function discoverRepos() {
|
|
try {
|
|
const client = new GiteaClient();
|
|
const repos = await client.get<any[]>("/user/repos?limit=100");
|
|
const authenticatedUser = BOT_USER.toLowerCase();
|
|
|
|
let newWebhooks = 0;
|
|
let newCollab = 0;
|
|
|
|
for (const repo of repos) {
|
|
const name = repo.full_name;
|
|
if (knownRepos.has(name)) continue;
|
|
|
|
const isOwner = repo.owner?.login?.toLowerCase() === authenticatedUser;
|
|
|
|
if (WEBHOOK_URL && isOwner) {
|
|
// Own repo — try to install webhook
|
|
try {
|
|
const webhook = await client.post<any>(`/repos/${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,
|
|
});
|
|
webhookRepos.set(name, { webhookId: webhook.id, addedAt: Date.now() });
|
|
knownRepos.add(name);
|
|
// Own repos default to "all"
|
|
if (!repoConfigs.has(name)) repoConfigs.set(name, { respondTo: "all" });
|
|
newWebhooks++;
|
|
console.log(`[gitea-repos] ✅ Webhook: ${name} (ID: ${webhook.id})`);
|
|
continue;
|
|
} catch (err) {
|
|
if (!(err instanceof GiteaError && err.status === 403)) {
|
|
console.error(`[gitea-repos] ❌ Webhook error: ${name}: ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
// Fall through to collab handling
|
|
}
|
|
}
|
|
|
|
// Collab repo (or own repo without webhook URL) — notification-polled
|
|
collabRepos.add(name);
|
|
knownRepos.add(name);
|
|
// Collab repos default to "mention" only
|
|
if (!repoConfigs.has(name)) repoConfigs.set(name, { respondTo: "mention" });
|
|
newCollab++;
|
|
console.log(`[gitea-repos] 📋 Collab (mention-only): ${name}`);
|
|
}
|
|
|
|
if (newWebhooks > 0 || newCollab > 0) {
|
|
console.log(`[gitea-repos] Discovered: ${newWebhooks} webhook, ${newCollab} collab`);
|
|
await saveState();
|
|
}
|
|
console.log(`[gitea-repos] Total: ${webhookRepos.size} webhook + ${collabRepos.size} collab repos`);
|
|
} catch (err) {
|
|
console.error("[gitea-repos] Error discovering:", err instanceof Error ? err.message : err);
|
|
}
|
|
}
|
|
|
|
// ── Notification Polling ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Poll Gitea notifications API for @mentions and assignments.
|
|
* Much more efficient than scanning every repo — single API call.
|
|
* Gitea handles the @mention detection for us.
|
|
*/
|
|
async function pollNotifications() {
|
|
try {
|
|
const client = new GiteaClient();
|
|
|
|
// Fetch unread notifications
|
|
const notifications = await client.get<any[]>(
|
|
"/notifications?status-types=unread&limit=20",
|
|
);
|
|
|
|
if (notifications.length === 0) return;
|
|
|
|
console.log(`[gitea-notif] ${notifications.length} unread notifications`);
|
|
|
|
for (const notif of notifications) {
|
|
const notifId = notif.id;
|
|
const repo = notif.repository?.full_name;
|
|
const subjectType = notif.subject?.type; // "Issue", "Pull", "Commit"
|
|
const subjectUrl = notif.subject?.url; // API URL for the issue/PR
|
|
const latestCommentUrl = notif.subject?.latest_comment_url;
|
|
|
|
if (!repo || !subjectUrl) {
|
|
await markNotifRead(client, notifId);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
// Determine what triggered the notification:
|
|
// - If latest_comment_url exists and differs from subject_url, it's a comment
|
|
// - Otherwise it's the issue/PR itself
|
|
let eventCtx: EventContext | null = null;
|
|
|
|
if (latestCommentUrl && latestCommentUrl !== subjectUrl) {
|
|
// Notification triggered by a comment
|
|
const commentPath = latestCommentUrl.replace(/.*\/api\/v1/, "");
|
|
console.log(`[gitea-notif] Fetching comment: ${commentPath}`);
|
|
const comment = await client.get<any>(commentPath);
|
|
const body = comment.body || "";
|
|
const author = comment.user?.login || "";
|
|
|
|
// Skip our own comments
|
|
if (author.toLowerCase() === BOT_USER.toLowerCase()) {
|
|
await markNotifRead(client, notifId);
|
|
continue;
|
|
}
|
|
|
|
const mention = parseMentions(body, BOT_USER);
|
|
|
|
// Extract issue/PR number from subject URL
|
|
const numberMatch = subjectUrl.match(/\/(\d+)$/);
|
|
const parentNumber = numberMatch ? parseInt(numberMatch[1], 10) : undefined;
|
|
|
|
eventCtx = {
|
|
repo, type: "issue_comment", action: "created",
|
|
author, body, parentNumber, mention,
|
|
source: "notification",
|
|
};
|
|
} else {
|
|
// Notification triggered by the issue/PR itself
|
|
const subjectPath = subjectUrl.replace(/.*\/api\/v1/, "");
|
|
console.log(`[gitea-notif] Fetching subject: ${subjectPath} (type: ${subjectType})`);
|
|
const subject = await client.get<any>(subjectPath);
|
|
const body = subject.body || "";
|
|
const author = subject.user?.login || "";
|
|
|
|
if (author.toLowerCase() === BOT_USER.toLowerCase()) {
|
|
await markNotifRead(client, notifId);
|
|
continue;
|
|
}
|
|
|
|
const mention = parseMentions(body, BOT_USER);
|
|
|
|
if (subjectType === "Issue") {
|
|
eventCtx = {
|
|
repo, type: "issue", action: "opened",
|
|
number: subject.number, title: subject.title,
|
|
author, body, labels: subject.labels?.map((l: any) => l.name),
|
|
mention, source: "notification",
|
|
};
|
|
} else if (subjectType === "Pull") {
|
|
eventCtx = {
|
|
repo, type: "pull_request", action: "opened",
|
|
number: subject.number, title: subject.title,
|
|
author, body,
|
|
baseBranch: subject.base?.label,
|
|
headBranch: subject.head?.label,
|
|
mention, source: "notification",
|
|
};
|
|
}
|
|
}
|
|
|
|
if (eventCtx) {
|
|
const mentionTag = eventCtx.mention?.mentioned ? " 🔔" : "";
|
|
console.log(`[gitea-notif] ${eventCtx.type} on ${repo}#${eventCtx.number ?? eventCtx.parentNumber} by @${eventCtx.author}${mentionTag}`);
|
|
|
|
const prompt = formatPrompt(eventCtx);
|
|
enqueuePrompt(prompt);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[gitea-notif] Error processing notification ${notifId}:`, err instanceof Error ? err.message : err);
|
|
}
|
|
|
|
// Mark as read
|
|
await markNotifRead(client, notifId);
|
|
}
|
|
} catch (err) {
|
|
console.error("[gitea-notif] Error polling:", err instanceof Error ? err.message : err);
|
|
}
|
|
}
|
|
|
|
async function markNotifRead(client: GiteaClient, notifId: number) {
|
|
try {
|
|
await client.patch(`/notifications/threads/${notifId}`, {});
|
|
} catch {
|
|
// Best effort — don't fail the whole poll
|
|
}
|
|
}
|
|
|
|
// ── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
export async function startPolling(_pi: ExtensionAPI) {
|
|
// Load persisted state
|
|
const saved = await loadState();
|
|
for (const [repo, config] of Object.entries(saved.repoConfigs)) {
|
|
repoConfigs.set(repo, config);
|
|
}
|
|
|
|
// Discover repos immediately, then on interval
|
|
void discoverRepos();
|
|
repoPollTimer = setInterval(() => void discoverRepos(), POLL_INTERVAL * 1000);
|
|
console.log(`[gitea-polling] Repo discovery started (interval: ${POLL_INTERVAL}s)`);
|
|
|
|
// Notification polling on a fast interval
|
|
notifPollTimer = setInterval(() => void pollNotifications(), NOTIF_POLL_INTERVAL * 1000);
|
|
console.log(`[gitea-polling] Notification polling started (interval: ${NOTIF_POLL_INTERVAL}s)`);
|
|
}
|
|
|
|
export function stopPolling() {
|
|
if (repoPollTimer) {
|
|
clearInterval(repoPollTimer);
|
|
repoPollTimer = null;
|
|
}
|
|
if (notifPollTimer) {
|
|
clearInterval(notifPollTimer);
|
|
notifPollTimer = null;
|
|
}
|
|
webhookRepos.clear();
|
|
collabRepos.clear();
|
|
knownRepos.clear();
|
|
}
|
|
|
|
export function getTrackedRepos() {
|
|
return {
|
|
webhook: new Map(webhookRepos),
|
|
collab: new Set(collabRepos),
|
|
configs: new Map(repoConfigs),
|
|
};
|
|
}
|
|
|
|
/** Set respondTo mode for a repo. Returns the new config. */
|
|
export function setRepoConfig(repo: string, config: Partial<RepoConfig>): RepoConfig {
|
|
const current = repoConfigs.get(repo) ?? { respondTo: "mention" };
|
|
const updated = { ...current, ...config };
|
|
repoConfigs.set(repo, updated);
|
|
void saveState();
|
|
return updated;
|
|
}
|
|
|
|
export { parseMentions, type MentionInfo, type RepoConfig, type ClaimRequest, SESSION_ID, WEBHOOK_PORT, WEBHOOK_HOST };
|