#!/usr/bin/env node // Step 1 — PR fetch (deterministic; runs gh; large-PR-safe; NO scratch dir). // // Replaces a bare `gh pr view … > capture/pr.json` in the orchestrator. It folds // the PR into the two artifacts ingest.mjs consumes: // capture/pr.json the gh pr view core (title, body, author, refs, commits, // reviews, comments, assignees, +/− stats, …) with its `files` // list COMPLETED via a paginated `gh api .../pulls/N/files` // call — `gh pr view --json files` truncates at ~100 files, so a // big PR would otherwise lose the tail. Commits keep gh pr view's // rich `authors[]` (co-authors) — only `files` needs the override. // For MERGED PRs it also stamps a best-effort `shipped_version` // (+ `version_source`) so the end card / cta doesn't invent one. // capture/diff.patch the full unified diff (`gh pr diff`). // // gh runs HERE so auth / not-found / private-repo errors surface with gh's own stderr // and exit 1 (the orchestrator then stops). Intermediates are held in memory — this // writes ONLY the two files above, so there is no `_ingest_tmp/` scratch to clean up // (the previous "let the agent fetch in pieces" approach polluted videos/ and was // non-deterministic). ingest.mjs stays a pure offline transform downstream. // // Usage: // node fetch-pr.mjs --pr "" [--out-dir ./capture] // // Exit 0 = capture/pr.json + capture/diff.patch written + summary on stdout. // Exit 1 = gh not authenticated / PR not found / pr view failed. import { execFileSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; const argv = process.argv.slice(2); const flag = (name, def) => { const i = argv.indexOf(`--${name}`); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def; }; function die(msg) { console.error(`✗ fetch-pr.mjs: ${msg}`); process.exit(1); } const prRef = flag("pr", null); if (!prRef) die('--pr "" is required'); const outDir = resolve(flag("out-dir", "./capture")); // Run gh, capture stdout. Returns { ok, stdout, stderr } — never throws (callers // decide whether a failure is fatal). 64 MB buffer covers large diffs / file lists. function ghTry(args) { try { const stdout = execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); return { ok: true, stdout, stderr: "" }; } catch (e) { return { ok: false, stdout: (e.stdout || "").toString(), stderr: (e.stderr || e.message || "").toString().trim(), }; } } // ── 0. auth — fail fast with gh's own hint ─────────────────────────────────── if (!ghTry(["auth", "status"]).ok) { die("gh is not authenticated — run: gh auth login"); } // ── 1. core PR object (gh pr view) ─────────────────────────────────────────── const FIELDS = [ "number", "title", "body", "author", "url", "baseRefName", "headRefName", "commits", "files", "additions", "deletions", "changedFiles", "labels", "reviews", "latestReviews", "comments", "assignees", "reviewDecision", "mergedBy", "state", "mergedAt", ].join(","); const view = ghTry(["pr", "view", prRef, "--json", FIELDS]); if (!view.ok) die(`gh pr view "${prRef}" failed (auth / not found / private?):\n${view.stderr}`); let pr; try { pr = JSON.parse(view.stdout); } catch (e) { die(`gh pr view returned unparseable JSON (${e.message})`); } // ── 2. complete the files list via paginated gh api (the truncation fix) ────── // gh pr view --json files caps at ~100 files; the REST endpoint paginates with no // cap. --jq runs per page, so the output is NDJSON (one file object per line). const number = pr.number; const m = /github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/.exec(pr.url || ""); const owner = m?.[1]; const repo = m?.[2]; let filesNote = `${Array.isArray(pr.files) ? pr.files.length : 0} (from pr view)`; if (owner && repo && number != null) { const apiFiles = ghTry([ "api", "--paginate", `repos/${owner}/${repo}/pulls/${number}/files`, "--jq", ".[] | {path: .filename, additions, deletions, status}", ]); if (apiFiles.ok) { const files = apiFiles.stdout .split("\n") .filter(Boolean) .map((l) => { try { return JSON.parse(l); } catch { return null; } }) .filter(Boolean); if (files.length) { pr.files = files; if (pr.changedFiles == null || files.length > pr.changedFiles) pr.changedFiles = files.length; filesNote = `${files.length} (completed via gh api)`; } } else { console.error( ` (warn: gh api files failed — keeping pr view's files: ${apiFiles.stderr.split("\n")[0]})`, ); } } else { console.error(" (warn: could not parse owner/repo from PR url — keeping pr view's files)"); } // ── 2.5 best-effort shipping version (MERGED PRs only) ─────────────────────── // The end card / cta ("upgrade to vN", "what's new in vN") wants a real version; // a PR carries none, so the agent would otherwise guess. We resolve one here and // stamp it onto pr.json as `shipped_version` (+ a `version_source` note that keeps // it honest). `git tag --contains` isn't available on a remote-only fetch, so we // use gh api proxies: the first release published at/after the merge is the first // tag that can contain the merge commit; failing that, the default branch's // package manifest version (unreleased); else null. Always best-effort — a lookup // failure just leaves the fields null (the skill then falls back to the repo URL). pr.shipped_version = null; pr.version_source = null; if (pr.state === "MERGED") { const mergedAt = pr.mergedAt ? Date.parse(pr.mergedAt) : NaN; // (a) earliest non-draft release published on/after the merge. if (owner && repo && !Number.isNaN(mergedAt)) { const rel = ghTry([ "api", "--paginate", `repos/${owner}/${repo}/releases`, "--jq", ".[] | select(.draft == false) | {tag: .tag_name, published: .published_at}", ]); if (rel.ok) { let best = null; for (const line of rel.stdout.split("\n").filter(Boolean)) { let r; try { r = JSON.parse(line); } catch { continue; } if (!r?.tag || !r?.published) continue; const t = Date.parse(r.published); if (Number.isNaN(t) || t < mergedAt) continue; if (!best || t < best.t) best = { tag: r.tag, t }; } if (best) { pr.shipped_version = best.tag; pr.version_source = "first release published at/after merge"; } } else { console.error(` (warn: gh api releases failed: ${rel.stderr.split("\n")[0]})`); } } // (b) fallback — default branch's package manifest version (change merged but not // yet in a tagged release). Marked as unreleased so the skill doesn't present // it as a shipped tag. if (pr.shipped_version == null && owner && repo) { const pkg = ghTry(["api", `repos/${owner}/${repo}/contents/package.json`, "--jq", ".content"]); if (pkg.ok && pkg.stdout.trim()) { try { const manifest = JSON.parse(Buffer.from(pkg.stdout.trim(), "base64").toString("utf8")); if (manifest?.version) { pr.shipped_version = String(manifest.version); pr.version_source = "default-branch package.json (unreleased)"; } } catch { /* not JSON / no version — leave null */ } } } } // ── 3. write capture/pr.json + capture/diff.patch ──────────────────────────── mkdirSync(outDir, { recursive: true }); const prJsonPath = join(outDir, "pr.json"); writeFileSync(prJsonPath, JSON.stringify(pr, null, 2) + "\n"); const diff = ghTry(["pr", "diff", prRef]); const diffPath = join(outDir, "diff.patch"); if (diff.ok) { writeFileSync(diffPath, diff.stdout); } else { // The brief still builds without the diff (ingest treats it as optional), so this // is a warning, not fatal — but surface it. console.error( ` (warn: gh pr diff failed — brief builds without it: ${diff.stderr.split("\n")[0]})`, ); } // ── 4. summary ─────────────────────────────────────────────────────────────── const repoLabel = owner && repo ? `${owner}/${repo}` : "(repo?)"; console.log( [ `✓ fetch-pr: ${repoLabel} PR #${number ?? "?"} — "${(pr.title || "").slice(0, 72)}"`, ` files: ${filesNote}; diff: ${diff.ok ? `${diff.stdout.length} chars` : "MISSING"}`, ` shipped_version: ${pr.shipped_version ?? "null"}${pr.version_source ? ` (${pr.version_source})` : ""}`, ` wrote ${prJsonPath}${diff.ok ? ` + ${diffPath}` : ""}`, ].join("\n"), );