184 lines
7.3 KiB
TypeScript
184 lines
7.3 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";
|
|
|
|
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<string, string> }) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
|
|
|
|
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<string | { name?: string }>;
|
|
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<typeof parseForgejoRef>, token: string | undefined, fetcher: ForgejoFetch): Promise<ForgejoItem> {
|
|
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();
|
|
}
|