138 lines
5.7 KiB
TypeScript
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";
|
|
}
|