Setting the file. One moment.
Fetch PR · PR To Video · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page scripts/fetch-pr.mjs
scripts/ fetch-pr.mjs
JavaScript · 234 lines · 9 KB
14 // capture/diff.patch the full unified diff (`gh pr diff`).
15 //
16 // gh runs HERE so auth / not-found / private-repo errors surface with gh's own stderr
17 // and exit 1 (the orchestrator then stops). Intermediates are held in memory — this
18 // writes ONLY the two files above, so there is no `_ingest_tmp/` scratch to clean up
19 // (the previous "let the agent fetch in pieces" approach polluted videos/ and was
20 // non-deterministic). ingest.mjs stays a pure offline transform downstream.
21 //
22 // Usage:
23 // node fetch-pr.mjs --pr "<url | owner/repo#N | N>" [--out-dir ./capture]
24 //
25 // Exit 0 = capture/pr.json + capture/diff.patch written + summary on stdout.
26 // Exit 1 = gh not authenticated / PR not found / pr view failed.
27
28 import { execFileSync } from "node:child_process" ;
29 import { mkdirSync, writeFileSync } from "node:fs" ;
30 import { join, resolve } from "node:path" ;
31
32 const argv = process.argv. slice ( 2 );
33 const flag = ( name , def ) => {
34 const i = argv. indexOf ( `--${ name }` );
35 return i >= 0 && i + 1 < argv. length ? argv[i + 1 ] : def;
36 };
37 function die ( msg ) {
38 console. error ( `✗ fetch-pr.mjs: ${ msg }` );
39 process. exit ( 1 );
40 }
41
42 const prRef = flag ( "pr" , null );
43 if ( ! prRef) die ( '--pr "<url | owner/repo#N | N>" is required' );
44 const outDir = resolve ( flag ( "out-dir" , "./capture" ));
45
46 // Run gh, capture stdout. Returns { ok, stdout, stderr } — never throws (callers
47 // decide whether a failure is fatal). 64 MB buffer covers large diffs / file lists.
48 function ghTry ( args ) {
49 try {
50 const stdout = execFileSync ( "gh" , args, { encoding: "utf8" , maxBuffer: 64 * 1024 * 1024 });
51 return { ok: true , stdout, stderr: "" };
52 } catch (e) {
53 return {
54 ok: false ,
55 stdout: (e.stdout || "" ). toString (),
56 stderr: (e.stderr || e.message || "" ). toString (). trim (),
57 };
58 }
59 }
60
61 // ── 0. auth — fail fast with gh's own hint ───────────────────────────────────
62 if ( ! ghTry ([ "auth" , "status" ]).ok) {
63 die ( "gh is not authenticated — run: gh auth login" );
64 }
65
66 // ── 1. core PR object (gh pr view) ───────────────────────────────────────────
67 const FIELDS = [
68 "number" ,
69 "title" ,
70 "body" ,
71 "author" ,
72 "url" ,
73 "baseRefName" ,
74 "headRefName" ,
75 "commits" ,
76 "files" ,
77 "additions" ,
78 "deletions" ,
79 "changedFiles" ,
80 "labels" ,
81 "reviews" ,
82 "latestReviews" ,
83 "comments" ,
84 "assignees" ,
85 "reviewDecision" ,
86 "mergedBy" ,
87 "state" ,
88 "mergedAt" ,
89 ]. join ( "," );
90
91 const view = ghTry ([ "pr" , "view" , prRef, "--json" , FIELDS ]);
92 if ( ! view.ok) die ( `gh pr view "${ prRef }" failed (auth / not found / private?): \n ${ view . stderr }` );
93
94 let pr;
95 try {
96 pr = JSON . parse (view.stdout);
97 } catch (e) {
98 die ( `gh pr view returned unparseable JSON (${ e . message })` );
99 }
100
101 // ── 2. complete the files list via paginated gh api (the truncation fix) ──────
102 // gh pr view --json files caps at ~100 files; the REST endpoint paginates with no
103 // cap. --jq runs per page, so the output is NDJSON (one file object per line).
104 const number = pr.number;
105 const m = /github \. com \/ ( [ ^ /] + ) \/ ( [ ^ /] + ) \/ pull \/ \d + / . exec (pr.url || "" );
106 const owner = m?.[ 1 ];
107 const repo = m?.[ 2 ];
108 let filesNote = `${ Array . isArray ( pr . files ) ? pr . files . length : 0 } (from pr view)` ;
109 if (owner && repo && number != null ) {
110 const apiFiles = ghTry ([
111 "api" ,
112 "--paginate" ,
113 `repos/${ owner }/${ repo }/pulls/${ number }/files` ,
114 "--jq" ,
115 ".[] | {path: .filename, additions, deletions, status}" ,
116 ]);
117 if (apiFiles.ok) {
118 const files = apiFiles.stdout
119 . split ( " \n " )
120 . filter (Boolean)
121 . map (( l ) => {
122 try {
123 return JSON . parse (l);
124 } catch {
125 return null ;
126 }
127 })
128 . filter (Boolean);
129 if (files. length ) {
130 pr.files = files;
131 if (pr.changedFiles == null || files. length > pr.changedFiles) pr.changedFiles = files. length ;
132 filesNote = `${ files . length } (completed via gh api)` ;
133 }
134 } else {
135 console. error (
136 ` (warn: gh api files failed — keeping pr view's files: ${ apiFiles . stderr . split ( " \n " )[ 0 ] })` ,
137 );
138 }
139 } else {
140 console. error ( " (warn: could not parse owner/repo from PR url — keeping pr view's files)" );
141 }
142
143 // ── 2.5 best-effort shipping version (MERGED PRs only) ───────────────────────
144 // The end card / cta ("upgrade to vN", "what's new in vN") wants a real version;
145 // a PR carries none, so the agent would otherwise guess. We resolve one here and
146 // stamp it onto pr.json as `shipped_version` (+ a `version_source` note that keeps
147 // it honest). `git tag --contains` isn't available on a remote-only fetch, so we
148 // use gh api proxies: the first release published at/after the merge is the first
149 // tag that can contain the merge commit; failing that, the default branch's
150 // package manifest version (unreleased); else null. Always best-effort — a lookup
151 // failure just leaves the fields null (the skill then falls back to the repo URL).
152 pr.shipped_version = null ;
153 pr.version_source = null ;
154 if (pr.state === "MERGED" ) {
155 const mergedAt = pr.mergedAt ? Date. parse (pr.mergedAt) : NaN ;
156
157 // (a) earliest non-draft release published on/after the merge.
158 if (owner && repo && ! Number. isNaN (mergedAt)) {
159 const rel = ghTry ([
160 "api" ,
161 "--paginate" ,
162 `repos/${ owner }/${ repo }/releases` ,
163 "--jq" ,
164 ".[] | select(.draft == false) | {tag: .tag_name, published: .published_at}" ,
165 ]);
166 if (rel.ok) {
167 let best = null ;
168 for ( const line of rel.stdout. split ( " \n " ). filter (Boolean)) {
169 let r;
170 try {
171 r = JSON . parse (line);
172 } catch {
173 continue ;
174 }
175 if ( ! r?.tag || ! r?.published) continue ;
176 const t = Date. parse (r.published);
177 if (Number. isNaN (t) || t < mergedAt) continue ;
178 if ( ! best || t < best.t) best = { tag: r.tag, t };
179 }
180 if (best) {
181 pr.shipped_version = best.tag;
182 pr.version_source = "first release published at/after merge" ;
183 }
184 } else {
185 console. error ( ` (warn: gh api releases failed: ${ rel . stderr . split ( " \n " )[ 0 ] })` );
186 }
187 }
188
189 // (b) fallback — default branch's package manifest version (change merged but not
190 // yet in a tagged release). Marked as unreleased so the skill doesn't present
191 // it as a shipped tag.
192 if (pr.shipped_version == null && owner && repo) {
193 const pkg = ghTry ([ "api" , `repos/${ owner }/${ repo }/contents/package.json` , "--jq" , ".content" ]);
194 if (pkg.ok && pkg.stdout. trim ()) {
195 try {
196 const manifest = JSON . parse (Buffer. from (pkg.stdout. trim (), "base64" ). toString ( "utf8" ));
197 if (manifest?.version) {
198 pr.shipped_version = String (manifest.version);
199 pr.version_source = "default-branch package.json (unreleased)" ;
200 }
201 } catch {
202 /* not JSON / no version — leave null */
203 }
204 }
205 }
206 }
207
208 // ── 3. write capture/pr.json + capture/diff.patch ────────────────────────────
209 mkdirSync (outDir, { recursive: true });
210 const prJsonPath = join (outDir, "pr.json" );
211 writeFileSync (prJsonPath, JSON . stringify (pr, null , 2 ) + " \n " );
212
213 const diff = ghTry ([ "pr" , "diff" , prRef]);
214 const diffPath = join (outDir, "diff.patch" );
215 if (diff.ok) {
216 writeFileSync (diffPath, diff.stdout);
217 } else {
218 // The brief still builds without the diff (ingest treats it as optional), so this
219 // is a warning, not fatal — but surface it.
220 console. error (
221 ` (warn: gh pr diff failed — brief builds without it: ${ diff . stderr . split ( " \n " )[ 0 ] })` ,
222 );
223 }
224
225 // ── 4. summary ───────────────────────────────────────────────────────────────
226 const repoLabel = owner && repo ? `${ owner }/${ repo }` : "(repo?)" ;
227 console. log (
228 [
229 `✓ fetch-pr: ${ repoLabel } PR #${ number ?? "?"} — "${ ( pr . title || "" ). slice ( 0 , 72 ) }"` ,
230 ` files: ${ filesNote }; diff: ${ diff . ok ? `${ diff . stdout . length } chars` : "MISSING"}` ,
231 ` shipped_version: ${ pr . shipped_version ?? "null"}${ pr . version_source ? ` (${ pr . version_source })` : ""}` ,
232 ` wrote ${ prJsonPath }${ diff . ok ? ` + ${ diffPath }` : ""}` ,
233 ]. join ( " \n " ),
234 );