clownfish/scripts/lib.mjs
2026-04-25 04:25:23 -07:00

229 lines
6.3 KiB
JavaScript
Executable File

import fs from "node:fs";
import path from "node:path";
export function repoRoot() {
return path.resolve(import.meta.dirname, "..");
}
export function readText(relativePath) {
return fs.readFileSync(path.join(repoRoot(), relativePath), "utf8");
}
export function parseJob(filePath) {
const absolute = path.resolve(filePath);
const raw = fs.readFileSync(absolute, "utf8");
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) {
throw new Error(`missing YAML frontmatter: ${filePath}`);
}
return {
path: absolute,
relativePath: path.relative(repoRoot(), absolute),
frontmatter: parseSimpleYaml(match[1]),
body: match[2].trim(),
raw,
};
}
export function parseSimpleYaml(text) {
const out = {};
let currentKey = null;
for (const line of text.split(/\r?\n/)) {
if (!line.trim() || line.trimStart().startsWith("#")) continue;
const listMatch = line.match(/^\s+-\s+(.*)$/);
if (listMatch && currentKey) {
if (!Array.isArray(out[currentKey])) out[currentKey] = [];
out[currentKey].push(parseScalar(listMatch[1]));
continue;
}
const kv = line.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
if (!kv) {
throw new Error(`unsupported YAML line: ${line}`);
}
currentKey = kv[1];
const value = kv[2] ?? "";
out[currentKey] = value === "" ? [] : parseScalar(value);
}
return out;
}
function parseScalar(value) {
const trimmed = value.trim();
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (trimmed === "null") return null;
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
return trimmed
.slice(1, -1)
.split(",")
.map((part) => parseScalar(part))
.filter((part) => part !== "");
}
return trimmed;
}
export function validateJob(job) {
const errors = [];
const fm = job.frontmatter;
requireString(errors, fm, "repo");
requireString(errors, fm, "cluster_id");
requireString(errors, fm, "mode");
requireArray(errors, fm, "allowed_actions");
requireArray(errors, fm, "candidates");
if (typeof fm.repo === "string" && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(fm.repo)) {
errors.push("repo must be owner/repo");
}
if (fm.mode && !["plan", "execute", "autonomous"].includes(fm.mode)) {
errors.push("mode must be plan, execute, or autonomous");
}
for (const key of [
"allowed_actions",
"blocked_actions",
"require_human_for",
"canonical",
"candidates",
"cluster_refs",
]) {
if (fm[key] !== undefined && !Array.isArray(fm[key])) {
errors.push(`${key} must be a list`);
}
}
for (const action of fm.allowed_actions ?? []) {
if (!["comment", "label", "close", "merge", "fix", "raise_pr"].includes(action)) {
errors.push(`unsupported allowed action: ${action}`);
}
}
for (const ref of [...(fm.canonical ?? []), ...(fm.candidates ?? [])]) {
if (!/^#?[0-9]+$/.test(String(ref))) {
errors.push(`candidate refs must look like #123: ${ref}`);
}
}
for (const ref of fm.cluster_refs ?? []) {
if (!isGithubRef(ref)) {
errors.push(`cluster_refs must look like #123 or a GitHub issue/PR URL: ${ref}`);
}
}
for (const key of [
"allow_instant_close",
"allow_fix_pr",
"allow_merge",
"allow_post_merge_close",
]) {
if (fm[key] !== undefined && typeof fm[key] !== "boolean") {
errors.push(`${key} must be true or false`);
}
}
for (const key of ["canonical_hint", "target_checkout"]) {
if (fm[key] !== undefined && typeof fm[key] !== "string") {
errors.push(`${key} must be a string`);
}
}
return errors;
}
function requireString(errors, object, key) {
if (typeof object[key] !== "string" || object[key].trim() === "") {
errors.push(`${key} is required`);
}
}
function requireArray(errors, object, key) {
if (!Array.isArray(object[key]) || object[key].length === 0) {
errors.push(`${key} must be a non-empty list`);
}
}
export function renderPrompt(job, requestedMode, context = {}) {
const mode = requestedMode ?? job.frontmatter.mode;
const modePrompt =
mode === "autonomous"
? "prompts/autonomous.md"
: mode === "execute"
? "prompts/execute.md"
: "prompts/plan-only.md";
const parts = [
readText("prompts/worker-system.md"),
readText(modePrompt),
"## Dedupe policy",
readText("instructions/dedupe.md"),
"## Closure policy",
readText("instructions/closure-policy.md"),
"## Merge policy",
readText("instructions/merge-policy.md"),
"## Job file",
"```md",
job.raw.trim(),
"```",
];
for (const [title, filePath] of [
["Cluster preflight artifact", context.clusterPlanPath],
["Fix artifact", context.fixArtifactPath],
]) {
if (!filePath) continue;
const absolute = path.resolve(filePath);
parts.push(`## ${title}`, `Path: \`${path.relative(repoRoot(), absolute)}\``, "```json", fs.readFileSync(absolute, "utf8").trim(), "```");
}
parts.push(
"## Required final output",
"Return JSON matching `schemas/codex-result.schema.json` and nothing else.",
);
return parts.join("\n\n");
}
function isGithubRef(value) {
const text = String(value ?? "");
return /^#?[0-9]+$/.test(text) || /^https:\/\/github\.com\/[^/]+\/[^/]+\/(?:issues|pull)\/[0-9]+/.test(text);
}
export function parseArgs(argv) {
const args = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) {
args._.push(arg);
continue;
}
const key = arg.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
args[key] = true;
} else {
args[key] = next;
i += 1;
}
}
return args;
}
export function assertAllowedOwner(repo, allowedOwner) {
if (!allowedOwner) return;
const owner = repo.split("/")[0];
if (owner !== allowedOwner) {
throw new Error(`repo owner ${owner} does not match CLOWNFISH_ALLOWED_OWNER=${allowedOwner}`);
}
}
export function makeRunDir(job, mode) {
const slug = `${path.basename(job.path, ".md")}-${mode}-${new Date().toISOString().replace(/[:.]/g, "-")}`;
const dir = path.join(repoRoot(), ".projectclownfish", "runs", slug);
fs.mkdirSync(dir, { recursive: true });
return dir;
}