254 lines · 9.8 KB · TypeScript
RawDownload
/** * Parse a .split-plan.md file into structured data for execution. * WHY forgiving parser: Humans and LLMs edit the plan; we accept variations in formatting * (e.g. "Route to: PR #42" vs "**Route to:** PR #42 (title)") and extract what we need. */import { readFileSync } from 'fs';import { parsePRUrl } from '../prr/github/types.js'; export interface ParsedPlan {  sourcePrUrl: string;  owner: string;  repo: string;  sourceBranch: string;  targetBranch: string;  splits: ParsedSplit[];} export interface ParsedSplit {  index: number;  title: string;  /** Optional PR/commit title that matches repo style (e.g. conventional commit). Used for commit message and GitHub PR title. */  prTitle: string | null;  /** When set, route commits to this existing PR number. */  routeToPrNumber: number | null;  /** When set, create a new branch and open a new PR with this branch name. */  newBranch: string | null;  /** File paths for this split. When non-empty, we apply these files from source branch (new commit) instead of cherry-picking. */  files: string[];  /** Commit SHAs (short or full); used only when files is empty (cherry-pick mode). */  commits: string[];  /** Raw lines for the commits section (for diagnostics when commits.length === 0). */  rawCommitLines: string[];} const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n/;/** Second frontmatter block (e.g. duplicate from LLM); skip so body starts at real content. */const SECOND_FRONTMATTER_REGEX = /^\s*\n?---\s*\n[\s\S]*?\n---\s*\n/;const SPLIT_HEADER_REGEX = /^###\s+(\d+)\.\s+(.+)$/;const ROUTE_TO_REGEX = /\*\*Route to:\*\*\s*PR\s*#(\d+)/i;const NEW_PR_REGEX = /\*\*New PR:\*\*\s*`([^`]+)`/i;/** **PR title:** `feat: add workflow` or **Title:** chore: add workflow — use for commit and PR title to match repo style */const PR_TITLE_REGEX = /\*\*(?:PR title|Title):\*\*\s*(?:`([^`]+)`|(.+?))$/i;/** Inline: **Commits:** sha1, sha2 */const COMMITS_LINE_REGEX = /\*\*Commits:\*\*\s*(.+?)(?:\n|$)/i;/** Bullet line with backtick-wrapped SHA: - `12d870a` (optional note) or * `90eeab4` */const COMMIT_BULLET_REGEX = /^\s*[-*]\s+`([a-fA-F0-9]{7,40})`/;/** **Files:** section header */const FILES_LINE_REGEX = /\*\*Files:\*\*/i;/** Bullet with backtick-wrapped path; we only treat as file if it looks like a path (has / or .ext), not a commit SHA. */const FILE_BULLET_REGEX = /^\s*[-*]\s+`([^`]+)`/;function looksLikePath(s: string): boolean {  const t = s.trim();  if (/^[a-fA-F0-9]{7,40}$/.test(t)) return false;  return t.includes('/') || t.startsWith('.') || /\.(yml|yaml|tsx?|jsx?|md|json|ts|js|mjs|cjs|css|html)$/.test(t);} /** * Parse YAML-like frontmatter for the four keys we need. * WHY simple: Full YAML is overkill; we only need source_pr, source_branch, target_branch (generated_at optional). */function parseFrontmatter(yaml: string): Record<string, string> {  const out: Record<string, string> = {};  for (const line of yaml.split('\n')) {    const m = line.match(/^([a-z_]+):\s*(.*)$/);    if (m) out[m[1].trim()] = m[2].trim();  }  return out;} /** Commit SHA format (short 7 or full 40 hex chars). Used to fail fast on typos in the plan. */const COMMIT_SHA_REGEX = /^[0-9a-f]{7,40}$/i; function isValidCommitSha(sha: string): boolean {  return COMMIT_SHA_REGEX.test(sha);} /** * Extract commit SHAs from a "**Commits:** abc1234, def5678" line. * WHY flexible: Plan may use short (7) or full SHAs; we accept comma- or space-separated. */function parseCommitsLine(text: string): string[] {  const trimmed = text.trim();  if (!trimmed) return [];  return trimmed.split(/[\s,]+/).map(s => s.trim()).filter(Boolean);} /** Validate all commit SHAs in parsed splits; throw with clear message on first invalid SHA. */function validateCommitShas(splits: ParsedSplit[]): void {  for (const split of splits) {    for (const sha of split.commits) {      if (!isValidCommitSha(sha)) {        throw new Error(`Invalid plan: commit "${sha}" in split ${split.index} ("${split.title}") is not a valid SHA (expected 7–40 hex chars).`);      }    }  }} /** * Reject branch names that break fetch refspecs (`+refs/heads/a:b`) or `git checkout`. * WHY: LLMs often put conventional-commit titles in **New PR:**; those must live in **PR title:** instead. */export function assertValidGitBranchName(name: string, context: string): void {  const t = name.trim();  if (!t) {    throw new Error(`Invalid plan: empty branch name (${context})`);  }  if (t.includes('..') || t.includes('//') || t.startsWith('/') || t.endsWith('/')) {    throw new Error(`Invalid plan: branch "${name}" (${context}) — avoid .., //, or leading/trailing /.`);  }  if (t.endsWith('.lock')) {    throw new Error(`Invalid plan: branch "${name}" (${context}) must not end with .lock`);  }  if (t.includes('@{')) {    throw new Error(`Invalid plan: branch "${name}" (${context}) must not contain @{`);  }  // Colon breaks refspec parsing; space and metacharacters are invalid or unsafe for refs/heads/*  if (/[:\s~^?*[\]\\]/.test(t) || /[\x00-\x1f]/.test(t)) {    throw new Error(      `Invalid plan: branch "${name}" (${context}) is not a valid git branch slug (forbidden: :, spaces, ~, ^, ?, *, [, \\, control chars). Use a short path like feat/foo-bar in **New PR:** and the full conventional title in **PR title:**.`    );  }} function validateBranchNames(sourceBranch: string, targetBranch: string, splits: ParsedSplit[]): void {  assertValidGitBranchName(sourceBranch, 'frontmatter source_branch');  assertValidGitBranchName(targetBranch, 'frontmatter target_branch');  for (const split of splits) {    if (split.newBranch != null) {      assertValidGitBranchName(split.newBranch, `split ${split.index} ("${split.title}") **New PR:**`);    }  }} /** * Parse the plan file and return structured data for execution. * WHY throw on invalid: Caller should not run with a broken plan; we validate required fields. */export function parsePlanFile(planPath: string): ParsedPlan {  const raw = readFileSync(planPath, 'utf-8');  const frontMatch = raw.match(FRONTMATTER_REGEX);  if (!frontMatch) {    throw new Error(`Invalid plan: no YAML frontmatter (--- ... ---) in ${planPath}`);  }  const frontmatter = parseFrontmatter(frontMatch[1]);  const sourcePrUrl = frontmatter.source_pr;  const sourceBranch = frontmatter.source_branch;  const targetBranch = frontmatter.target_branch;  if (!sourcePrUrl || !sourceBranch || !targetBranch) {    throw new Error(      `Invalid plan: frontmatter must have source_pr, source_branch, target_branch (got ${Object.keys(frontmatter).join(', ')})`    );  }  let owner: string;  let repo: string;  try {    ({ owner, repo } = parsePRUrl(sourcePrUrl));  } catch {    const m = sourcePrUrl.match(/github\.com\/([^/]+)\/([^/]+)/);    if (m) {      owner = m[1];      repo = m[2].replace(/\.git$/, '');    } else {      throw new Error(`Invalid plan: source_pr is not a valid PR URL: ${sourcePrUrl}`);    }  }   let body = raw.slice(frontMatch[0].length);  // WHY strip second frontmatter: LLMs sometimes emit duplicate --- blocks; skip so body starts at real content.  const secondFront = body.match(SECOND_FRONTMATTER_REGEX);  if (secondFront) body = body.slice(secondFront[0].length);  const splits = parseSplits(body);  validateCommitShas(splits);  validateBranchNames(sourceBranch, targetBranch, splits);   return {    sourcePrUrl,    owner,    repo,    sourceBranch,    targetBranch,    splits,  };} /** * Parse ## Split section into an ordered array of splits. * WHY line-by-line: Plan format is "### N. Title" then key-value lines; we scan for ### and then collect until next ### or ##. */function parseSplits(body: string): ParsedSplit[] {  const splits: ParsedSplit[] = [];  const lines = body.split('\n');  let i = 0;  while (i < lines.length) {    const headerMatch = lines[i].match(SPLIT_HEADER_REGEX);    if (!headerMatch) {      i++;      continue;    }    const index = parseInt(headerMatch[1], 10);    const title = headerMatch[2].trim();    let prTitle: string | null = null;    let routeToPrNumber: number | null = null;    let newBranch: string | null = null;    let files: string[] = [];    let commits: string[] = [];    const rawCommitLines: string[] = [];    let inFilesSection = false;    i++;    while (i < lines.length && !lines[i].match(/^###\s+\d+\./) && !lines[i].startsWith('## ')) {      const line = lines[i];      if (line.match(/\*\*\w+:\*\*/)) {        if (line.match(FILES_LINE_REGEX)) inFilesSection = true;        else inFilesSection = false;      } else if (inFilesSection) {        const fileMatch = line.match(FILE_BULLET_REGEX);        if (fileMatch && looksLikePath(fileMatch[1])) files.push(fileMatch[1].trim());      }      const prTitleMatch = line.match(PR_TITLE_REGEX);      if (prTitleMatch) prTitle = (prTitleMatch[1] ?? prTitleMatch[2] ?? '').trim() || null;      const routeMatch = line.match(ROUTE_TO_REGEX);      if (routeMatch) routeToPrNumber = parseInt(routeMatch[1], 10);      const newMatch = line.match(NEW_PR_REGEX);      if (newMatch) newBranch = newMatch[1].trim();      const commitsMatch = line.match(COMMITS_LINE_REGEX);      if (commitsMatch) {        inFilesSection = false;        const inline = parseCommitsLine(commitsMatch[1]);        if (inline.length > 0) commits.push(...inline);        rawCommitLines.push(line);      } else {        const bulletMatch = line.match(COMMIT_BULLET_REGEX);        if (bulletMatch) {          commits.push(bulletMatch[1]);          rawCommitLines.push(line);        }      }      i++;    }    if (routeToPrNumber == null && newBranch == null) {      continue;    }    splits.push({      index,      title,      prTitle,      routeToPrNumber: routeToPrNumber ?? null,      newBranch: newBranch ?? null,      files,      commits,      rawCommitLines,    });  }  return splits;}