import * as fs from "node:fs"; import * as path from "node:path"; import { findPromptInjection, type PromptInjectionFinding } from "../core/prompt-injection-safety.js"; import { emitChannelEvent } from "./channel.js"; export type ForgejoIntakeKind = "issue" | "pull" | "wiki"; export type ForgejoRoute = "security" | "seo" | "sales-marketing" | "client-report" | "user-feedback" | "data-pipeline" | "devops" | "dev" | "wiki" | "triage" | "human-review"; export interface ForgejoIntakeSource { type: `forgejo_${ForgejoIntakeKind}`; url?: string; repo: string; id: string; } export interface ForgejoItem { source: ForgejoIntakeSource; title: string; body: string; labels: string[]; } export interface ForgejoIntakeReceipt { source: ForgejoIntakeSource; task: { title: string; body: string; labels: string[]; route: ForgejoRoute; priority: "normal" | "high"; }; receipts: { created_at: string; fetched_by: "fable-agent"; unicode_scan: "passed" | "failed"; injection_scan: "passed" | "failed"; deploy_attempted: false; quarantine: boolean; findings: PromptInjectionFinding[]; }; } type ForgejoFetch = (input: string, init?: { headers?: Record }) => Promise<{ ok: boolean; status: number; json(): Promise }>; export interface ForgejoIntakeOptions { ref: string; repo?: string; baseUrl?: string; token?: string; outDir?: string; dryRun?: boolean; runId?: string; channelRoot?: string; now?: Date; fetcher?: ForgejoFetch; } type ForgejoApiItem = { title?: string; body?: string; content?: string; labels?: Array; html_url?: string; url?: string; }; export function routeForgejoLabels(labels: string[]): ForgejoRoute { const normalized = labels.map((label) => label.toLowerCase()); if (hasAny(normalized, ["security", "sec", "sec-first"])) return "security"; if (hasAny(normalized, ["seo"])) return "seo"; if (hasAny(normalized, ["sales", "marketing", "sale-marketing", "sales-marketing"])) return "sales-marketing"; if (hasAny(normalized, ["client", "report", "client-report"])) return "client-report"; if (hasAny(normalized, ["user", "users", "feedback", "feedbackpilot", "user-feedback"])) return "user-feedback"; if (hasAny(normalized, ["data", "pipeline", "data-pipeline"])) return "data-pipeline"; if (hasAny(normalized, ["devops", "ops", "infra", "ci", "cd"])) return "devops"; if (hasAny(normalized, ["dev", "bug", "feature"])) return "dev"; if (hasAny(normalized, ["wiki", "docs"])) return "wiki"; return "triage"; } export function createForgejoIntakeReceipt(item: ForgejoItem, now = new Date()): ForgejoIntakeReceipt { const findings = findPromptInjection(`${item.title}\n${item.body}`); const quarantine = findings.length > 0; return { source: item.source, task: { title: item.title, body: item.body, labels: item.labels, route: quarantine ? "human-review" : routeForgejoLabels(item.labels), priority: item.labels.some((label) => /critical|urgent|high/i.test(label)) ? "high" : "normal", }, receipts: { created_at: now.toISOString(), fetched_by: "fable-agent", unicode_scan: findings.some((f) => f.kind === "hidden-unicode") ? "failed" : "passed", injection_scan: findings.some((f) => f.kind !== "hidden-unicode") ? "failed" : "passed", deploy_attempted: false, quarantine, findings, }, }; } export async function intakeForgejoItem(opts: ForgejoIntakeOptions): Promise<{ receipt: ForgejoIntakeReceipt; path?: string }> { const parsed = parseForgejoRef(opts.ref, opts.repo); if (opts.runId) emitChannelEvent(opts.runId, { source: "intake", type: "started", data: { ref: opts.ref, repo: parsed.repo, kind: parsed.kind } }, opts.channelRoot, opts.now); const configuredBase = opts.baseUrl?.replace(/\/$/, "")?.replace(/\/$/, ""); const baseUrl = configuredBase ?? parsed.baseUrl; if (!baseUrl) throw new Error("FORGEJO_URL is required unless ISSUE is a full Forgejo URL"); if (parsed.baseUrl && configuredBase && normalizedOrigin(parsed.baseUrl) !== normalizedOrigin(configuredBase)) { throw new Error("Forgejo reference host does not match configured FORGEJO_URL"); } // ponytail: only send FORGEJO_TOKEN to configured base URL; ref-selected origins without a configured base get no token. const token = configuredBase ? opts.token : undefined; const item = await fetchForgejoItem(baseUrl, parsed, token, opts.fetcher ?? fetch); const receipt = createForgejoIntakeReceipt(item, opts.now); if (opts.runId) emitChannelEvent(opts.runId, { source: "intake", type: "receipt", data: receipt }, opts.channelRoot, opts.now); if (opts.dryRun) return { receipt }; const outDir = opts.outDir ?? ".fable/tasks"; fs.mkdirSync(outDir, { recursive: true }); const file = path.join(outDir, `forgejo-${parsed.kind}-${safeFilePart(parsed.id)}.json`); fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`); return { receipt, path: file }; } export function parseForgejoRef(ref: string, repo?: string): { kind: ForgejoIntakeKind; repo: string; id: string; baseUrl?: string; url?: string } { const url = parseUrl(ref); if (url) { const parts = url.pathname.split("/").filter(Boolean); const markerIndex = parts.findIndex((p) => ["issues", "pulls", "wiki"].includes(p)); if (markerIndex < 2) throw new Error(`Could not parse Forgejo ref: ${ref}`); const kind = parts[markerIndex] === "pulls" ? "pull" : parts[markerIndex] === "wiki" ? "wiki" : "issue"; return { kind, repo: `${parts[markerIndex - 2]}/${parts[markerIndex - 1]}`, id: decodeURIComponent(parts.slice(markerIndex + 1).join("/")), baseUrl: url.origin, url: ref, }; } if (!repo) throw new Error("--repo is required when ISSUE is not a URL"); return { kind: "issue", repo, id: ref }; } async function fetchForgejoItem(baseUrl: string, parsed: ReturnType, token: string | undefined, fetcher: ForgejoFetch): Promise { const apiPath = parsed.kind === "wiki" ? `/api/v1/repos/${parsed.repo}/wiki/page/${encodeURIComponent(parsed.id)}` : `/api/v1/repos/${parsed.repo}/${parsed.kind === "pull" ? "pulls" : "issues"}/${encodeURIComponent(parsed.id)}`; const res = await fetcher(`${baseUrl}${apiPath}`, { headers: token ? { Authorization: `token ${token}` } : undefined }); if (!res.ok) throw new Error(`Forgejo API returned HTTP ${res.status}`); const json = await res.json() as ForgejoApiItem; const labels = (json.labels ?? []).map((label) => typeof label === "string" ? label : label.name ?? "").filter(Boolean); return { source: { type: `forgejo_${parsed.kind}`, url: parsed.url ?? json.html_url ?? json.url, repo: parsed.repo, id: parsed.id }, title: json.title ?? parsed.id, body: json.body ?? json.content ?? "", labels, }; } function hasAny(labels: string[], needles: string[]): boolean { return needles.some((needle) => labels.includes(needle) || labels.some((label) => label.endsWith(`/${needle}`))); } function parseUrl(value: string): URL | undefined { try { return new URL(value); } catch { return undefined; } } function safeFilePart(value: string): string { return value .replace(/[^A-Za-z0-9_.-]+/g, "-") .replace(/(^-+)|(-+$)/g, "") || "item"; } function normalizedOrigin(value: string): string { return new URL(value).origin.replace(/\/$/, "").toLowerCase(); }