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"; import { parseForgejoRef } from "./forgejo-intake.js"; export type ForgejoWritebackAction = "comment" | "label"; type ForgejoWritebackFetch = (input: string, init?: { method?: string; headers?: Record; body?: string }) => Promise<{ ok: boolean; status: number; json(): Promise }>; export interface ForgejoWritebackOptions { ref: string; action: ForgejoWritebackAction; body?: string; labels?: string[]; repo?: string; baseUrl?: string; token?: string; outDir?: string; dryRun?: boolean; runId?: string; channelRoot?: string; now?: Date; fetcher?: ForgejoWritebackFetch; } export interface ForgejoWritebackReceipt { schema: "fable.forgejo.writeback.v1"; createdAt: string; source: { ref: string; repo: string; id: string; kind: "issue" | "pull" | "wiki"; }; action: ForgejoWritebackAction; request: { body?: string; labels?: string[]; }; result: { status: "dry-run" | "posted" | "blocked"; url?: string; httpStatus?: number; }; receipts: { unicode_scan: "passed" | "failed"; injection_scan: "passed" | "failed"; deploy_attempted: false; quarantine: boolean; findings: PromptInjectionFinding[]; }; } export async function writebackForgejo(opts: ForgejoWritebackOptions): Promise<{ receipt: ForgejoWritebackReceipt; path?: string }> { const parsed = parseForgejoRef(opts.ref, opts.repo); if (parsed.kind === "wiki") throw new Error("Forgejo writeback supports issues and PRs only"); const body = opts.body; const labels = opts.labels?.filter(Boolean); if (opts.action === "comment" && !body?.trim()) throw new Error("comment body is required"); if (opts.action === "label" && (!labels || labels.length === 0)) throw new Error("at least one label is required"); const findings = findPromptInjection(`${body ?? ""}\n${labels?.join("\n") ?? ""}`); const quarantine = findings.length > 0; const receipt = createReceipt(opts, parsed, findings, quarantine); if (!quarantine && !opts.dryRun) { const baseUrl = configuredBaseUrl(opts.baseUrl, parsed.baseUrl); const token = baseUrl.fromConfig ? opts.token : undefined; const res = await postWriteback({ ...opts, baseUrl: baseUrl.url, token, labels }, parsed, opts.fetcher ?? fetch); receipt.result.status = "posted"; receipt.result.httpStatus = res.status; receipt.result.url = res.url; } if (quarantine) receipt.result.status = "blocked"; 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-writeback-${opts.action}-${safeFilePart(parsed.id)}.json`); fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`); return { receipt, path: file }; } function createReceipt(opts: ForgejoWritebackOptions, parsed: ReturnType, findings: PromptInjectionFinding[], quarantine: boolean): ForgejoWritebackReceipt { return { schema: "fable.forgejo.writeback.v1", createdAt: (opts.now ?? new Date()).toISOString(), source: { ref: opts.ref, repo: parsed.repo, id: parsed.id, kind: parsed.kind }, action: opts.action, request: { body: opts.body, labels: opts.labels }, result: { status: opts.dryRun ? "dry-run" : quarantine ? "blocked" : "dry-run" }, receipts: { 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, }, }; } async function postWriteback(opts: ForgejoWritebackOptions & { baseUrl: string; labels?: string[] }, parsed: ReturnType, fetcher: ForgejoWritebackFetch): Promise<{ status: number; url?: string }> { const base = opts.baseUrl.replace(/\/$/, ""); const kindPath = parsed.kind === "pull" ? "pulls" : "issues"; const endpoint = opts.action === "comment" ? `${base}/api/v1/repos/${parsed.repo}/${kindPath}/${encodeURIComponent(parsed.id)}/comments` : `${base}/api/v1/repos/${parsed.repo}/issues/${encodeURIComponent(parsed.id)}/labels`; const res = await fetcher(endpoint, { method: "POST", headers: { "content-type": "application/json", ...(opts.token ? { Authorization: `token ${opts.token}` } : {}), }, body: JSON.stringify(opts.action === "comment" ? { body: opts.body } : { labels: opts.labels }), }); if (!res.ok) throw new Error(`Forgejo API returned HTTP ${res.status}`); const json = await res.json() as { html_url?: string; url?: string }; return { status: res.status, url: json.html_url ?? json.url }; } function configuredBaseUrl(baseUrl: string | undefined, parsedBaseUrl: string | undefined): { url: string; fromConfig: boolean } { const configured = baseUrl?.replace(/\/$/, ""); if (!configured && !parsedBaseUrl) throw new Error("FORGEJO_URL is required unless ref is a full Forgejo URL"); if (configured && parsedBaseUrl && normalizedOrigin(configured) !== normalizedOrigin(parsedBaseUrl)) throw new Error("Forgejo reference host does not match configured FORGEJO_URL"); return { url: configured ?? parsedBaseUrl!, fromConfig: Boolean(configured) }; } function normalizedOrigin(value: string): string { return new URL(value).origin.replace(/\/$/, "").toLowerCase(); } function safeFilePart(value: string): string { return value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/(^-+)|(-+$)/g, "") || "item"; }