fable-agent/src/fable5/forgejo-writeback.ts

138 lines
5.7 KiB
TypeScript

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<string, string>; body?: string }) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
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<typeof parseForgejoRef>, 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<typeof parseForgejoRef>, 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";
}