feat: @mention routing via Gitea notifications API
- Replace per-repo event polling with notification-based polling - Collab repos default to mention-only mode (respond only when @mentioned) - Own repos default to all-events mode (respond to everything) - Parse @mentions and extract directive text for focused LLM context - Notification poll interval: 30s (configurable via PI_NOTIF_POLL_INTERVAL) - Mark notifications as read after processing (no ID tracking needed) - Add gitea_repo_config tool to switch repos between 'all' and 'mention' modes - Add gitea_tracked_repos tool to show all repos and their response modes - Persist per-repo configs to disk across reloads - Multi-bot coordination: issues can @mention different bots with directives
This commit is contained in:
@@ -2,22 +2,83 @@
|
||||
* pi-gitea Extension — entry point
|
||||
*
|
||||
* Registers Gitea tools (read + write) and optional webhook server.
|
||||
* Supports @mention routing for multi-bot coordination.
|
||||
*/
|
||||
|
||||
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 {
|
||||
startWebhookServer, stopWebhookServer,
|
||||
startPolling, stopPolling,
|
||||
setSendMessage, getTrackedRepos, setRepoConfig,
|
||||
} from "./webhook/server.js";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
registerReadTools(pi);
|
||||
registerWriteTools(pi);
|
||||
|
||||
// ── Repo config tool ─────────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_repo_config",
|
||||
label: "Gitea: Configure Repo Response Mode",
|
||||
description:
|
||||
'Set how the bot responds to events on a repo. ' +
|
||||
'"all" = respond to every event. ' +
|
||||
'"mention" = respond only when @mentioned or assigned. ' +
|
||||
'Collab repos default to "mention", own repos default to "all".',
|
||||
parameters: Type.Object({
|
||||
repo: Type.String({ description: "Repository (owner/name)" }),
|
||||
respondTo: Type.String({ description: '"all" or "mention"' }),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const mode = params.respondTo as "all" | "mention";
|
||||
if (mode !== "all" && mode !== "mention") {
|
||||
return {
|
||||
content: [{ type: "text", text: `Invalid mode "${params.respondTo}". Use "all" or "mention".` }],
|
||||
};
|
||||
}
|
||||
const config = setRepoConfig(params.repo, { respondTo: mode });
|
||||
return {
|
||||
content: [{ type: "text", text: `✅ ${params.repo}: respondTo = ${config.respondTo}` }],
|
||||
details: { repo: params.repo, config },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "gitea_tracked_repos",
|
||||
label: "Gitea: List Tracked Repos",
|
||||
description: "Show all tracked repos, their type (webhook/collab), and response mode.",
|
||||
parameters: Type.Object({}),
|
||||
async execute() {
|
||||
const { webhook, collab, configs } = getTrackedRepos();
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const [name] of webhook) {
|
||||
const mode = configs.get(name)?.respondTo ?? "all";
|
||||
lines.push(`✅ ${name} — webhook (respondTo: ${mode})`);
|
||||
}
|
||||
for (const name of collab) {
|
||||
const mode = configs.get(name)?.respondTo ?? "mention";
|
||||
lines.push(`📋 ${name} — collab/notification (respondTo: ${mode})`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No repos tracked." }],
|
||||
details: { webhook: [...webhook.keys()], collab: [...collab], configs: Object.fromEntries(configs) },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
console.log("[pi-gitea] Session started");
|
||||
|
||||
const sendMessageFn = (msg: string) => {
|
||||
// Use followUp so events queue when the LLM is already processing
|
||||
ctx.sendUserMessage(msg, { deliverAs: "followUp" });
|
||||
return Promise.resolve();
|
||||
};
|
||||
@@ -25,9 +86,9 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
try {
|
||||
await startWebhookServer(pi);
|
||||
startPolling(pi);
|
||||
await startPolling(pi);
|
||||
} catch (err) {
|
||||
console.error("[pi-gitea] Failed to start webhook server:", err);
|
||||
console.error("[pi-gitea] Failed to start:", err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,116 +1,211 @@
|
||||
/**
|
||||
* Webhook server — receives Gitea events via HTTP + polls collab repos
|
||||
* Webhook server + notification poller
|
||||
*
|
||||
* Auth: Bearer token validation (PI_WEBHOOK_TOKEN).
|
||||
* No HMAC/secret — consistent with token-based auth strategy.
|
||||
* Two event sources:
|
||||
* 1. Webhooks (own repos with admin) — real-time, all events
|
||||
* 2. Notification polling (collab repos) — @mention/assign driven
|
||||
*
|
||||
* Repos where the bot has admin access get webhooks installed automatically.
|
||||
* Repos where the bot is a non-admin collaborator are polled for new events.
|
||||
* @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.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);
|
||||
const EVENT_POLL_INTERVAL = parseInt(process.env.PI_EVENT_POLL_INTERVAL ?? "60", 10);
|
||||
const NOTIF_POLL_INTERVAL = parseInt(process.env.PI_NOTIF_POLL_INTERVAL ?? "30", 10);
|
||||
const BOT_USER = process.env.PI_GIT_USER ?? "";
|
||||
|
||||
const POLL_STATE_FILE = "/home/pibot/.pi/agent/gitea-poll-state.json";
|
||||
const STATE_FILE = "/home/pibot/.pi/agent/gitea-poll-state.json";
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let server: Server | null = null;
|
||||
let processingQueue: Array<{ event: any; timestamp: number }> = [];
|
||||
let maxQueueDepth = 50;
|
||||
let processingQueue: Array<{ prompt: string; timestamp: number }> = [];
|
||||
const maxQueueDepth = 50;
|
||||
let isProcessing = false;
|
||||
let repoPollTimer: NodeJS.Timeout | null = null;
|
||||
let eventPollTimer: NodeJS.Timeout | null = null;
|
||||
let notifPollTimer: NodeJS.Timeout | null = null;
|
||||
let sendMessage: ((message: string) => Promise<void>) | null = null;
|
||||
|
||||
/** Repos where we successfully installed a webhook */
|
||||
let webhookRepos: Map<string, { webhookId: number; addedAt: number }> = new Map();
|
||||
|
||||
/** Repos where webhook install failed (403) — we poll these for events */
|
||||
let pollOnlyRepos: Map<string, { addedAt: number; lastPollAt: string }> = new Map();
|
||||
/** Repos where webhook install failed (403) — notification-polled */
|
||||
let collabRepos: Set<string> = new Set();
|
||||
|
||||
/** Track all known repos so we don't re-attempt webhook install every cycle */
|
||||
/** All known repos (avoid re-attempting webhook install) */
|
||||
let knownRepos: Set<string> = new Set();
|
||||
|
||||
/** Load poll timestamps from disk (survives reloads) */
|
||||
async function loadPollState(): Promise<Record<string, string>> {
|
||||
/** 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(POLL_STATE_FILE, "utf-8");
|
||||
return JSON.parse(data);
|
||||
const data = await fs.readFile(STATE_FILE, "utf-8");
|
||||
const parsed = JSON.parse(data);
|
||||
return {
|
||||
repoConfigs: parsed.repoConfigs ?? {},
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
return { repoConfigs: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/** Save poll timestamps to disk */
|
||||
async function savePollState(): Promise<void> {
|
||||
async function saveState(): Promise<void> {
|
||||
try {
|
||||
const fs = await import("node:fs/promises");
|
||||
const state: Record<string, string> = {};
|
||||
for (const [name, s] of pollOnlyRepos) {
|
||||
state[name] = s.lastPollAt;
|
||||
}
|
||||
await fs.writeFile(POLL_STATE_FILE, JSON.stringify(state), "utf-8");
|
||||
const state: PollState = {
|
||||
repoConfigs: Object.fromEntries(repoConfigs),
|
||||
};
|
||||
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), "utf-8");
|
||||
} catch (err) {
|
||||
console.error("[gitea-polling] Error saving poll state:", err instanceof Error ? err.message : err);
|
||||
console.error("[gitea-state] Error saving:", err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
export function setSendMessage(fn: (message: string) => Promise<void>) {
|
||||
sendMessage = fn;
|
||||
// ── @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;
|
||||
}
|
||||
|
||||
/** 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}`;
|
||||
/**
|
||||
* 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]);
|
||||
}
|
||||
|
||||
/** 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";
|
||||
const mentioned = allMentions.some(
|
||||
(m) => m.toLowerCase() === botUser.toLowerCase(),
|
||||
);
|
||||
|
||||
let prompt = `New Gitea event on ${repo}:\n\n`;
|
||||
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 (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 (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 (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 (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 (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 (type === "push") {
|
||||
prompt += `**Pusher**: @${ctx.author}\n\n`;
|
||||
}
|
||||
|
||||
if (event.pusher) {
|
||||
prompt += `**Pusher**: @${event.pusher.name}\n`;
|
||||
prompt += `**Commits**: ${event.commits?.length || 0}\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`;
|
||||
@@ -118,86 +213,94 @@ function formatEventPrompt(event: any): string {
|
||||
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 += `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;
|
||||
}
|
||||
|
||||
/** Format a polled issue/PR/comment as a synthetic event prompt */
|
||||
function formatPolledEvent(type: "issue" | "pull_request" | "issue_comment", item: any, repo: string): string {
|
||||
let prompt = `New Gitea event on ${repo} (via polling):\n\n`;
|
||||
prompt += `**Action**: opened\n\n`;
|
||||
// ── Event Queue ──────────────────────────────────────────────────────────────
|
||||
|
||||
if (type === "issue") {
|
||||
prompt += `**Issue #${item.number}: ${item.title}**\n`;
|
||||
prompt += `**Author**: @${item.user?.login || "unknown"}\n`;
|
||||
prompt += `**Labels**: ${item.labels?.map((l: any) => l.name).join(", ") || "none"}\n`;
|
||||
prompt += `**Body**:\n${item.body || "(no body)"}\n\n`;
|
||||
} else if (type === "pull_request") {
|
||||
prompt += `**PR #${item.number}: ${item.title}**\n`;
|
||||
prompt += `**Author**: @${item.user?.login || "unknown"}\n`;
|
||||
prompt += `**Base**: ${item.base?.label} ← **Head**: ${item.head?.label}\n`;
|
||||
prompt += `**Body**:\n${item.body || "(no body)"}\n\n`;
|
||||
} else if (type === "issue_comment") {
|
||||
prompt += `**Comment on #${item._issueNumber}**\n`;
|
||||
prompt += `**Author**: @${item.user?.login || "unknown"}\n`;
|
||||
prompt += `**Body**:\n${item.body || "(no body)"}\n\n`;
|
||||
export function setSendMessage(fn: (message: string) => Promise<void>) {
|
||||
sendMessage = fn;
|
||||
}
|
||||
|
||||
prompt += `---\n\n`;
|
||||
prompt += `Please analyze this event and decide how to respond.\n`;
|
||||
prompt += `Use the available Gitea tools to interact with the repository.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/** Enqueue an event for LLM processing */
|
||||
function enqueueEvent(event: any) {
|
||||
processingQueue.push({ event, timestamp: Date.now() });
|
||||
function enqueuePrompt(prompt: string) {
|
||||
processingQueue.push({ prompt, timestamp: Date.now() });
|
||||
|
||||
if (processingQueue.length > maxQueueDepth) {
|
||||
processingQueue.shift();
|
||||
console.warn(`[gitea-webhook] Queue full, dropped oldest event`);
|
||||
console.warn(`[gitea-events] Queue full, dropped oldest event`);
|
||||
}
|
||||
|
||||
void processQueue();
|
||||
}
|
||||
|
||||
/** 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 || event._repo || "unknown";
|
||||
|
||||
console.log(`[gitea-webhook] Processing event: ${event.action || event._type} on ${repoName}`);
|
||||
const { prompt } = processingQueue.shift()!;
|
||||
|
||||
if (sendMessage) {
|
||||
try {
|
||||
const prompt = event._polled
|
||||
? formatPolledEvent(event._type, event._item, event._repo)
|
||||
: formatEventPrompt(event);
|
||||
await sendMessage(prompt);
|
||||
console.log(`[gitea-webhook] Event sent to LLM: ${event.action || event._type} on ${repoName}`);
|
||||
console.log(`[gitea-events] Event delivered to LLM`);
|
||||
} catch (err) {
|
||||
console.error(`[gitea-webhook] Failed to send event to LLM:`, err);
|
||||
console.error(`[gitea-events] Failed to send to LLM:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[gitea-webhook] No sendMessage function available, skipping event`);
|
||||
console.warn(`[gitea-events] No sendMessage function, skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
isProcessing = false;
|
||||
}
|
||||
|
||||
// ── HTTP Server ──────────────────────────────────────────────────────────────
|
||||
// ── 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}`;
|
||||
}
|
||||
|
||||
/** Start webhook server */
|
||||
export function startWebhookServer(_pi: ExtensionAPI) {
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
try {
|
||||
@@ -219,16 +322,15 @@ export function startWebhookServer(_pi: ExtensionAPI) {
|
||||
// GET /health
|
||||
if (url === "/health" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
res.end(JSON.stringify({
|
||||
status: "ok",
|
||||
uptime: process.uptime(),
|
||||
bot_user: BOT_USER,
|
||||
webhook_repos: webhookRepos.size,
|
||||
poll_only_repos: pollOnlyRepos.size,
|
||||
collab_repos: collabRepos.size,
|
||||
queue_depth: processingQueue.length,
|
||||
is_processing: isProcessing,
|
||||
}),
|
||||
);
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -237,28 +339,25 @@ export function startWebhookServer(_pi: ExtensionAPI) {
|
||||
if (!validateToken(req)) {
|
||||
res.writeHead(401, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Unauthorized" }));
|
||||
console.error("[gitea-webhook] Token validation failed");
|
||||
return;
|
||||
}
|
||||
|
||||
let body = "";
|
||||
for await (const chunk of req) {
|
||||
body += chunk.toString();
|
||||
}
|
||||
let rawBody = "";
|
||||
for await (const chunk of req) rawBody += chunk.toString();
|
||||
|
||||
let event;
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(body);
|
||||
event = JSON.parse(rawBody);
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
|
||||
enqueueEvent(event);
|
||||
handleWebhookEvent(event);
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ received: true, event: event.action }));
|
||||
res.end(JSON.stringify({ received: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -278,7 +377,72 @@ export function startWebhookServer(_pi: ExtensionAPI) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop webhook server */
|
||||
/** 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) {
|
||||
@@ -293,24 +457,25 @@ export async function stopWebhookServer() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Repo Discovery & Webhook Registration ────────────────────────────────────
|
||||
// ── Repo Discovery ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Discover repos and install webhooks (or mark as poll-only on 403) */
|
||||
async function discoverRepos() {
|
||||
try {
|
||||
const client = new GiteaClient();
|
||||
const repos = await client.get<any[]>("/user/repos?limit=100");
|
||||
const savedState = await loadPollState();
|
||||
const authenticatedUser = BOT_USER.toLowerCase();
|
||||
|
||||
let newWebhooks = 0;
|
||||
let newPollOnly = 0;
|
||||
let newCollab = 0;
|
||||
|
||||
for (const repo of repos) {
|
||||
const name = repo.full_name;
|
||||
if (knownRepos.has(name)) continue;
|
||||
|
||||
// Try to install webhook
|
||||
if (WEBHOOK_URL) {
|
||||
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",
|
||||
@@ -322,159 +487,181 @@ async function discoverRepos() {
|
||||
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-polling] ✅ Webhook installed: ${name} (ID: ${webhook.id})`);
|
||||
console.log(`[gitea-repos] ✅ Webhook: ${name} (ID: ${webhook.id})`);
|
||||
continue;
|
||||
} catch (err) {
|
||||
if (err instanceof GiteaError && err.status === 403) {
|
||||
// No admin access — fall back to polling
|
||||
// Use persisted timestamp if available, otherwise 5 min ago
|
||||
const lastPoll = savedState[name] ?? new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
pollOnlyRepos.set(name, {
|
||||
addedAt: Date.now(),
|
||||
lastPollAt: lastPoll,
|
||||
});
|
||||
knownRepos.add(name);
|
||||
newPollOnly++;
|
||||
console.log(`[gitea-polling] 📋 Poll-only (no admin): ${name}`);
|
||||
continue;
|
||||
if (!(err instanceof GiteaError && err.status === 403)) {
|
||||
console.error(`[gitea-repos] ❌ Webhook error: ${name}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
// Other error — log but don't add to known (retry next cycle)
|
||||
console.error(`[gitea-polling] ❌ Webhook error for ${name}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
} else {
|
||||
// No webhook URL configured — all repos are poll-only
|
||||
const lastPoll = savedState[name] ?? new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
pollOnlyRepos.set(name, {
|
||||
addedAt: Date.now(),
|
||||
lastPollAt: lastPoll,
|
||||
});
|
||||
knownRepos.add(name);
|
||||
newPollOnly++;
|
||||
// Fall through to collab handling
|
||||
}
|
||||
}
|
||||
|
||||
if (newWebhooks > 0 || newPollOnly > 0) {
|
||||
console.log(`[gitea-polling] Discovered: ${newWebhooks} webhook, ${newPollOnly} poll-only`);
|
||||
// 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}`);
|
||||
}
|
||||
console.log(`[gitea-polling] Total: ${webhookRepos.size} webhook + ${pollOnlyRepos.size} poll-only repos`);
|
||||
|
||||
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-polling] Error discovering repos:", err instanceof Error ? err.message : err);
|
||||
console.error("[gitea-repos] Error discovering:", err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event Polling for Non-Admin Repos ────────────────────────────────────────
|
||||
// ── Notification Polling ─────────────────────────────────────────────────────
|
||||
|
||||
/** Poll all poll-only repos for new issues, PRs, and comments */
|
||||
async function pollForEvents() {
|
||||
if (pollOnlyRepos.size === 0) {
|
||||
console.log(`[gitea-poll-events] No poll-only repos to check`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[gitea-poll-events] Checking ${pollOnlyRepos.size} repos for new events...`);
|
||||
/**
|
||||
* 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();
|
||||
|
||||
for (const [repoName, state] of pollOnlyRepos) {
|
||||
// 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 {
|
||||
const since = state.lastPollAt;
|
||||
const sinceDate = new Date(since);
|
||||
const now = new Date().toISOString();
|
||||
const [owner, repo] = repoName.split("/");
|
||||
// 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;
|
||||
|
||||
// Fetch recent issues created/updated since last poll
|
||||
const issues = await client.get<any[]>(
|
||||
`/repos/${owner}/${repo}/issues?state=open&sort=created&type=issues&since=${since}&limit=20`
|
||||
);
|
||||
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 || "";
|
||||
|
||||
for (const issue of issues) {
|
||||
// Skip issues created by the bot itself
|
||||
if (issue.user?.login === BOT_USER) continue;
|
||||
|
||||
// Only process issues created after our last poll (not just updated)
|
||||
if (new Date(issue.created_at) > sinceDate) {
|
||||
console.log(`[gitea-poll-events] New issue: ${repoName}#${issue.number} "${issue.title}"`);
|
||||
enqueueEvent({
|
||||
_polled: true,
|
||||
_type: "issue",
|
||||
_item: issue,
|
||||
_repo: repoName,
|
||||
action: "opened",
|
||||
repository: { full_name: repoName },
|
||||
});
|
||||
// Skip our own comments
|
||||
if (author.toLowerCase() === BOT_USER.toLowerCase()) {
|
||||
await markNotifRead(client, notifId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for new comments on this issue
|
||||
try {
|
||||
const comments = await client.get<any[]>(
|
||||
`/repos/${owner}/${repo}/issues/${issue.number}/comments?since=${since}`
|
||||
);
|
||||
for (const comment of comments) {
|
||||
if (comment.user?.login === BOT_USER) continue;
|
||||
if (new Date(comment.created_at) > sinceDate) {
|
||||
console.log(`[gitea-poll-events] New comment on ${repoName}#${issue.number} by @${comment.user?.login}`);
|
||||
enqueueEvent({
|
||||
_polled: true,
|
||||
_type: "issue_comment",
|
||||
_item: { ...comment, _issueNumber: issue.number },
|
||||
_repo: repoName,
|
||||
action: "created",
|
||||
repository: { full_name: repoName },
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Comment fetch failed, skip
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recent PRs
|
||||
const prs = await client.get<any[]>(
|
||||
`/repos/${owner}/${repo}/pulls?state=open&sort=created&limit=20`
|
||||
);
|
||||
if (eventCtx) {
|
||||
const mentionTag = eventCtx.mention?.mentioned ? " 🔔" : "";
|
||||
console.log(`[gitea-notif] ${eventCtx.type} on ${repo}#${eventCtx.number ?? eventCtx.parentNumber} by @${eventCtx.author}${mentionTag}`);
|
||||
|
||||
for (const pr of prs) {
|
||||
if (pr.user?.login === BOT_USER) continue;
|
||||
|
||||
if (new Date(pr.created_at) > sinceDate) {
|
||||
console.log(`[gitea-poll-events] New PR: ${repoName}#${pr.number} "${pr.title}"`);
|
||||
enqueueEvent({
|
||||
_polled: true,
|
||||
_type: "pull_request",
|
||||
_item: pr,
|
||||
_repo: repoName,
|
||||
action: "opened",
|
||||
repository: { full_name: repoName },
|
||||
});
|
||||
const prompt = formatPrompt(eventCtx);
|
||||
enqueuePrompt(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last poll timestamp
|
||||
state.lastPollAt = now;
|
||||
} catch (err) {
|
||||
console.error(`[gitea-poll-events] Error polling ${repoName}: ${err instanceof Error ? err.message : 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist poll timestamps
|
||||
await savePollState();
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Start repo discovery polling + event polling */
|
||||
export function startPolling(_pi: ExtensionAPI) {
|
||||
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)`);
|
||||
|
||||
// Event polling for poll-only repos on a faster interval
|
||||
eventPollTimer = setInterval(() => void pollForEvents(), EVENT_POLL_INTERVAL * 1000);
|
||||
console.log(`[gitea-polling] Event polling started (interval: ${EVENT_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() {
|
||||
@@ -482,19 +669,30 @@ export function stopPolling() {
|
||||
clearInterval(repoPollTimer);
|
||||
repoPollTimer = null;
|
||||
}
|
||||
if (eventPollTimer) {
|
||||
clearInterval(eventPollTimer);
|
||||
eventPollTimer = null;
|
||||
if (notifPollTimer) {
|
||||
clearInterval(notifPollTimer);
|
||||
notifPollTimer = null;
|
||||
}
|
||||
// Clear state for clean reload
|
||||
webhookRepos.clear();
|
||||
pollOnlyRepos.clear();
|
||||
collabRepos.clear();
|
||||
knownRepos.clear();
|
||||
}
|
||||
|
||||
export function getTrackedRepos() {
|
||||
return {
|
||||
webhook: new Map(webhookRepos),
|
||||
pollOnly: new Map(pollOnlyRepos),
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user