173 lines · 7.1 KB · TypeScript
RawDownload
/** * Scan committed fixes from git history - recovery from interruption *  * WHY this module exists: * When prr is interrupted (Ctrl+C, crash, timeout), in-memory state is lost. * But git commits are durable. By scanning commit messages for prr-fix markers, * we can recover which issues were already verified without re-running verifications. *  * WHY scan git log instead of state files: * - Git commits can't be corrupted or deleted accidentally * - Works even if state file is missing or outdated * - Provides audit trail of what was actually committed *  * USAGE: Called at startup to restore verification state from previous runs. * * **In-process cache (workdir + branch + HEAD):** Repeated calls with the same refs reuse the * parsed comment-id list so recovery does not re-run `git log` / grep when nothing changed. * **WHY:** Pill noted redundant scans; the scan is bounded but still avoidable when setup and * recovery share one HEAD. Tests call **`clearScanCommittedFixesCache()`**. */import type { SimpleGit } from 'simple-git';import { debug } from '../logger.js'; /** In-process cache: same workdir + branch + HEAD → same grep scan (pill: avoid redundant git log). */const committedFixScanCache = new Map<string, string[]>();const MAX_SCAN_CACHE_ENTRIES = 64; function scanCacheKey(workdir: string, branch: string, headSha: string, prBaseBranch?: string): string {  const base = prBaseBranch?.trim() ?? '';  return `${workdir}\0${branch}\0${headSha}\0${base}`;} function rememberScanCache(key: string, ids: string[]): void {  if (committedFixScanCache.size >= MAX_SCAN_CACHE_ENTRIES) {    const firstKey = committedFixScanCache.keys().next().value as string | undefined;    if (firstKey !== undefined) committedFixScanCache.delete(firstKey);  }  committedFixScanCache.set(key, ids);} /** Clear process-wide scan cache (tests or long-lived hosts). */export function clearScanCommittedFixesCache(): void {  committedFixScanCache.clear();} export interface ScanCommittedFixesOptions {  /** When set with headSha, reuse a prior in-process result for this workdir/branch/HEAD. */  workdir?: string;  headSha?: string;  /**   * GitHub PR base branch name (e.g. `develop`, `v2.0.0`). When set, PRR tries `origin/<name>` first   * for the `base..branch` log range before falling back to origin/main|master|develop.   * WHY: Repos whose default is not main/master/develop used to leave `baseBranch` null in debug and   * rely on `-n 100`; using the real PR base matches pill/external audits expecting a proper merge range.   */  prBaseBranch?: string;} /** * Scan git commit messages for prr-fix markers to recover verification state *  * WHY scan only branch-specific commits: * Using `base..branch` range ensures we only look at commits created in this PR branch. * This prevents false positives from merged commits or commits from other branches. *  * WHY try multiple base branches: * Different repos use different default branch names (main, master, develop). * We try common names to maximize compatibility without requiring configuration. *  * WHY fallback to last 100 commits: * If we can't find a base branch (rare), limit search to recent history to avoid * scanning thousands of commits. 100 is generous - typical PRs have far fewer. *  * WHY use raw() instead of simple-git's log(): * simple-git doesn't support --grep properly, and we need exact control over * the git log command for reliable marker detection. * * WHY preserve original casing: * The state's verifiedFixed array stores IDs in their original case from the API. * We preserve casing from commit messages so markers match on recovery. * * WHY return empty array on error: * Failure to scan is not fatal - we'll just start with no recovered state and * verify everything fresh. Throwing would prevent startup on minor git issues. *  * @param git - SimpleGit instance for the repository * @param branch - Current PR branch name * @param opts - Optional cache: pass workdir + headSha to dedupe scans in one process * @returns Array of comment IDs that were previously committed (empty on error) */export async function scanCommittedFixes(  git: SimpleGit,  branch: string,  opts?: ScanCommittedFixesOptions): Promise<string[]> {  if (opts?.workdir && opts?.headSha) {    const key = scanCacheKey(opts.workdir, branch, opts.headSha, opts.prBaseBranch);    const hit = committedFixScanCache.get(key);    if (hit) {      debug('scanCommittedFixes (cache hit)', { branch, headSha: opts.headSha.slice(0, 7) });      return [...hit];    }  }  try {    // Find the base branch — PR's GitHub base first, then common default names    const baseBranches = ['origin/main', 'origin/master', 'origin/develop'];    let baseBranch: string | null = null;     const prBase = opts?.prBaseBranch?.trim();    if (prBase) {      const prRef = `origin/${prBase}`;      try {        await git.raw(['rev-parse', '--verify', prRef]);        baseBranch = prRef;      } catch {        // Single-branch clones may not have fetched base yet; fall through to heuristics      }    }     for (const candidate of baseBranches) {      if (baseBranch) break;      try {        await git.raw(['rev-parse', '--verify', candidate]);        baseBranch = candidate;        break;      } catch {        // Branch doesn't exist, try next      }    }        // If no common base branch found, fall back to searching all history    // WHY limit to 100: Prevents scanning thousands of commits in large repos    // WHY still safe: Typical PRs have < 20 commits, 100 is very generous    const logArgs = baseBranch      ? ['log', '--grep=prr-fix:', '--format=%B', `${baseBranch}..${branch}`]      : ['log', '--grep=prr-fix:', '--format=%B', '-n', '100'];        debug('scanCommittedFixes', { baseBranch, branch, logArgs });    const logOutput = await git.raw(logArgs);        const commentIds: string[] = [];        // Parse all prr-fix:ID markers from commit messages    // Format: One marker per line: "prr-fix:IC_kwDOAbc123_defGHI"    if (logOutput) {      const lines = logOutput.split('\n');      for (const line of lines) {        const match = line.match(/^prr-fix:(.+)$/);        if (match) {          // Preserve original casing from commit messages.          // WHY NOT lowercase: The state's verifiedFixed array stores IDs in          // their original case (from the GitHub API). Lowercasing here causes          // case-sensitive includes() checks to miss existing entries, leading          // to duplicate IDs accumulating across sessions.          commentIds.push(match[1].trim());        }      }    }        // Deduplicate: the same ID can appear in multiple commits    // (e.g., re-verified after a push, or re-committed after interruption)    const unique = [...new Set(commentIds)];    if (opts?.workdir && opts?.headSha) {      rememberScanCache(scanCacheKey(opts.workdir, branch, opts.headSha, opts.prBaseBranch), unique);    }    return unique;  } catch (error) {    // WHY catch and return empty instead of throw:    // Scan failure shouldn't prevent startup - we'll just verify everything fresh    debug('Failed to scan committed fixes', { error });    return [];  }}