Setting the file. One moment.
Ingest · PR To Video · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page — line 520
This file
Number 30.16
Position 16 of 32
Type JavaScript
Size 22 KB
Lines 561 scripts/ ingest.mjs
JavaScript · 561 lines · 22 KB
// headRefName,commits,files,additions,deletions,changedFiles,labels,
15 // reviews,latestReviews,comments,assignees,reviewDecision,mergedBy
16 // + fetch-pr.mjs's best-effort shipped_version / version_source
17 // --diff <path> gh pr diff (raw unified diff) [optional — brief still builds without it]
18 // Writes (under --out-dir, default ./capture/extracted):
19 // tokens.json synthetic design tokens (colors:[] → code-editorial native palette)
20 // visible-text.txt the narrative SOURCE: a readable plain-text brief assembled
21 // from title + meta + people + body + commits + changed files + a
22 // budget-bounded selection of representative diff hunks.
23 // people.json the contributors (PR author / commit authors / reviewers /
24 // commenters / assignees — the PR `author` is only the opener, so
25 // commit authors from commits[].authors[] are tracked separately),
26 // bot-filtered + deduped, each with a GitHub avatar URL + intended
27 // assets/<login>.png path. The avatars themselves are
28 // downloaded by the orchestrator (fetch-people-avatars.mjs) — THIS
29 // script stays offline. people.json + the avatars are the ONE place
30 // the faceless default is relaxed: an optional credits/shipped-by close.
31 //
32 // The story-design subagent reads visible-text.txt for the narrative AND gets the
33 // full diff.patch separately for deep hunk selection — so this brief is curated,
34 // not exhaustive: noisy files (lockfiles / dist / maps) are deprioritised so real
35 // source hunks win the char budget.
36 //
37 // Usage:
38 // node ingest.mjs --pr-json ./capture/pr.json --diff ./capture/diff.patch \
39 // --out-dir ./capture/extracted
40 //
41 // Exit 0 = tokens.json + visible-text.txt written + summary on stdout.
42 // Exit 1 = pr.json missing / unparseable (orchestrator should stop).
43
44 import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" ;
45 import { resolve, join } from "node:path" ;
46
47 // ---------- argv ----------
48 const argv = process.argv. slice ( 2 );
49 const flag = ( name , def ) => {
50 const i = argv. indexOf ( `--${ name }` );
51 return i >= 0 && i + 1 < argv. length ? argv[i + 1 ] : def;
52 };
53 function die ( msg ) {
54 console. error ( `✗ ingest.mjs: ${ msg }` );
55 process. exit ( 1 );
56 }
57
58 const prJsonPath = resolve ( flag ( "pr-json" , "./capture/pr.json" ));
59 const diffPath = flag ( "diff" ) ? resolve ( flag ( "diff" )) : resolve ( "./capture/diff.patch" );
60 const outDir = resolve ( flag ( "out-dir" , "./capture/extracted" ));
61
62 // Budgets — keep visible-text.txt readable and bounded for the story-design agent.
63 const MAX_BODY_CHARS = parseInt ( flag ( "max-body-chars" , "2600" ), 10 );
64 const MAX_DIFF_CHARS = parseInt ( flag ( "max-diff-chars" , "4800" ), 10 );
65 const MAX_HUNK_LINES = parseInt ( flag ( "max-hunk-lines" , "22" ), 10 ); // per hunk, post-context-trim
66 const MAX_COMMITS = parseInt ( flag ( "max-commits" , "12" ), 10 );
67 const MAX_FILES_LISTED = parseInt ( flag ( "max-files-listed" , "40" ), 10 );
68
69 // Noisy paths whose diff bodies rarely teach anything — deprioritised in hunk
70 // selection (still listed in "Files changed" with their stats).
71 const NOISE_RX =
72 /( ^| \/ )(package-lock \. json | yarn \. lock | pnpm-lock \. yaml | npm-shrinkwrap \. json | go \. sum | Cargo \. lock | composer \. lock | Gemfile \. lock | poetry \. lock) $| \. (min \. js | min \. css | map | snap) $| ( ^| \/ )(dist | build | out | vendor | node_modules | \. next | coverage) \/ / ;
73
74 // ---------- read pr.json ----------
75 if ( ! existsSync (prJsonPath)) die ( `pr.json not found at ${ prJsonPath } (run gh pr view first)` );
76 let pr;
77 try {
78 pr = JSON . parse ( readFileSync (prJsonPath, "utf8" ));
79 } catch (e) {
80 die ( `pr.json is not valid JSON (${ e . message }) — check the gh pr view output` );
81 }
82
83 // ---------- read diff (optional) ----------
84 let diffRaw = "" ;
85 if ( existsSync (diffPath)) {
86 try {
87 diffRaw = readFileSync (diffPath, "utf8" );
88 } catch {
89 diffRaw = "" ;
90 }
91 }
92
93 // ---------- derive scalars ----------
94 const number = pr.number ?? "?" ;
95 const title = (pr.title || `Pull request #${ number }` ). trim ();
96 const url = pr.url || "" ;
97 const repo = (() => {
98 const m = /github \. com \/ ( [ ^ /] + \/ [ ^ /] + ) \/ pull \/ / . exec (url);
99 if (m) return m[ 1 ];
100 if (pr.headRepository?.nameWithOwner) return pr.headRepository.nameWithOwner;
101 return "" ;
102 })();
103 const author = pr.author?.login || pr.author?.name || "unknown" ;
104 const baseRef = pr.baseRefName || "base" ;
105 const headRef = pr.headRefName || "head" ;
106 const additions = pr.additions ?? 0 ;
107 const deletions = pr.deletions ?? 0 ;
108 const changedFiles = pr.changedFiles ?? (Array. isArray (pr.files) ? pr.files. length : 0 );
109 const labels = Array. isArray (pr.labels)
110 ? pr.labels. map (( l ) => ( typeof l === "string" ? l : l?.name)). filter (Boolean)
111 : [];
112
113 // ---------- people (author / reviewers / commenters / assignees) ----------
114 // Offline bot heuristic — gh gives reviewer/commenter authors as a bare `login`
115 // (no `is_bot`), so we filter by the GitHub `[bot]` suffix + a denylist of the
116 // review/CI bots that dominate org PRs. Best-effort: a bot that slips through
117 // just gets an avatar downloaded and can still be excluded by story-design.
118 const BOT_DENYLIST = new Set (
119 [
120 "claude" ,
121 "graphite-app" ,
122 "dependabot" ,
123 "github-actions" ,
124 "codecov" ,
125 "codecov-commenter" ,
126 "coderabbitai" ,
127 "sonarcloud" ,
128 "sonarqubecloud" ,
129 "vercel" ,
130 "netlify" ,
131 "renovate" ,
132 "snyk-bot" ,
133 "greenkeeper" ,
134 "mergify" ,
135 "allcontributors" ,
136 "imgbot" ,
137 "pre-commit-ci" ,
138 "deepsource-autofix" ,
139 "sentry-io" ,
140 "semgrep-app" ,
141 "cubic-dev-ai" ,
142 "gemini-code-assist" ,
143 "copilot-pull-request-reviewer" ,
144 "github-advanced-security" ,
145 "restyled-io" ,
146 "changeset-bot" ,
147 "bundlemon" ,
148 ]. map (( s ) => s. toLowerCase ()),
149 );
150 const isBot = ( login ) => {
151 if ( ! login) return true ;
152 const l = login. toLowerCase ();
153 return l. endsWith ( "[bot]" ) || l. endsWith ( "-bot" ) || l. endsWith ( "[robot]" ) || BOT_DENYLIST . has (l);
154 };
155
156 // "author" = the PR opener; "committer" = wrote/co-authored commits in this PR
157 // (often differs from the opener — a teammate force-pushes the branch, or commits
158 // are co-authored). Commit authors are first-class contributors for a credits close.
159 const ROLE_ORDER = [ "author" , "committer" , "reviewer" , "commenter" , "assignee" ];
160 const peopleMap = new Map (); // login -> { login, name, roles:Set, reviewState, association, commitCount }
161 const botsFiltered = new Set ();
162 // Returns the person record for a real (non-bot) login, creating it on first
163 // touch; records and drops bots. null means "skip this login". `name` is the
164 // GitHub display name (e.g. "Miguel Angel Simon Sierra") — gh only hands this
165 // over for author/commits/mergedBy, not reviewers/commenters/assignees, so it's
166 // filled in opportunistically and the first non-empty value wins.
167 function consider ( login , name ) {
168 if ( ! login) return null ;
169 if ( isBot (login)) {
170 botsFiltered. add (login);
171 return null ;
172 }
173 if ( ! peopleMap. has (login))
174 peopleMap. set (login, {
175 login,
176 name: null ,
177 roles: new Set (),
178 reviewState: null ,
179 association: null ,
180 commitCount: 0 ,
181 });
182 const p = peopleMap. get (login);
183 if ( ! p.name && name) p.name = name;
184 return p;
185 }
186
187 const authorLogin = pr.author?.login || null ;
188 {
189 const p = consider (authorLogin, pr.author?.name);
190 if (p) p.roles. add ( "author" );
191 }
192
193 // Commit authors — the people who actually wrote the code. pr.commits[].authors[]
194 // carries login/name/email; co-authored commits list several. Counts drive ordering
195 // and the brief ("Name (@login, N commits)"). Authors with no GitHub login
196 // (email-only) can't be avatar'd, so they're skipped here.
197 for ( const c of Array. isArray (pr.commits) ? pr.commits : []) {
198 for ( const a of Array. isArray (c?.authors) ? c.authors : []) {
199 const p = consider (a?.login, a?.name);
200 if ( ! p) continue ;
201 p.roles. add ( "committer" );
202 p.commitCount += 1 ;
203 }
204 }
205
206 // Reviewers — prefer latestReviews (one row per reviewer, final state); fall back
207 // to reviews[] (all events → keep the last state per reviewer).
208 let reviewSource = Array. isArray (pr.latestReviews) ? pr.latestReviews : [];
209 if ( ! reviewSource. length && Array. isArray (pr.reviews)) {
210 const lastByAuthor = new Map ();
211 for ( const r of pr.reviews) {
212 const lg = r?.author?.login;
213 if (lg) lastByAuthor. set (lg, r); // later events overwrite earlier
214 }
215 reviewSource = [ ... lastByAuthor. values ()];
216 }
217 for ( const r of reviewSource) {
218 const p = consider (r?.author?.login, r?.author?.name);
219 if ( ! p) continue ;
220 p.roles. add ( "reviewer" );
221 if (r.state) p.reviewState = r.state;
222 if (r.authorAssociation) p.association = r.authorAssociation;
223 }
224
225 for ( const c of Array. isArray (pr.comments) ? pr.comments : []) {
226 const p = consider (c?.author?.login, c?.author?.name);
227 if (p) p.roles. add ( "commenter" );
228 }
229 for ( const a of Array. isArray (pr.assignees) ? pr.assignees : []) {
230 const p = consider (a?.login, a?.name);
231 if (p) p.roles. add ( "assignee" );
232 }
233
234 const REVIEW_STATE_LABEL = {
235 APPROVED: "approved" ,
236 CHANGES_REQUESTED: "changes requested" ,
237 COMMENTED: "commented" ,
238 DISMISSED: "dismissed" ,
239 PENDING: "pending" ,
240 };
241 const primaryRoleRank = ( roles ) => {
242 for ( let i = 0 ; i < ROLE_ORDER . length ; i ++ ) if (roles. includes ( ROLE_ORDER [i])) return i;
243 return ROLE_ORDER . length ;
244 };
245 const people = [ ... peopleMap. values ()]
246 . map (( p ) => ({
247 login: p.login,
248 // Display name for narration/on-screen credits — GitHub logins read aloud
249 // badly ("@miguAng18947550"). null when GitHub has no public name for this
250 // user and fetch-people-avatars.mjs couldn't resolve one either; the credits
251 // frame falls back to the login in that case.
252 name: p.name || null ,
253 roles: ROLE_ORDER . filter (( r ) => p.roles. has (r)),
254 commitCount: p.commitCount || 0 ,
255 reviewState: p.reviewState || null ,
256 association: p.association || null ,
257 // Unauthenticated avatar endpoint — redirects to the user's avatar; the
258 // orchestrator's fetch-people-avatars.mjs downloads it here.
259 avatarUrl: `https://github.com/${ encodeURIComponent ( p . login ) }.png?size=200` ,
260 avatarFile: `assets/${ p . login }.png` ,
261 avatarFetched: false , // set true by fetch-people-avatars.mjs once downloaded
262 }))
263 . sort (( a , b ) => primaryRoleRank (a.roles) - primaryRoleRank (b.roles));
264
265 const reviewDecision = pr.reviewDecision || null ;
266 const mergedByLogin = pr.mergedBy?.login || null ;
267
268 // Best-effort shipping version stamped by fetch-pr.mjs (MERGED PRs only). Surfaced
269 // in the brief so the end card / cta cites a real version instead of inventing one;
270 // null means "no version known — the close names the repo URL only" (see story-design.md).
271 const shippedVersion = typeof pr.shipped_version === "string" ? pr.shipped_version : null ;
272 const versionSource = typeof pr.version_source === "string" ? pr.version_source : null ;
273
274 // ---------- clean body ----------
275 function cleanBody ( raw ) {
276 if ( ! raw || typeof raw !== "string" ) return "" ;
277 // Strip HTML comments (PR templates) to a fixpoint, so fragments left by one
278 // pass can't reassemble into a new comment (CodeQL
279 // js/incomplete-multi-character-sanitization).
280 let t = raw;
281 for ( let prev = null ; prev !== t; ) {
282 prev = t;
283 t = t. replace ( /<!-- [\s\S] *? -->/ g , "" );
284 }
285 t = t
286 . replace ( / \r\n / g , " \n " )
287 . replace ( / \n {3,} / g , " \n\n " )
288 . trim ();
289 if (t. length > MAX_BODY_CHARS ) {
290 t = t. slice ( 0 , MAX_BODY_CHARS ). replace ( / \s + \S *$ / , "" ) + " \n …(description truncated)" ;
291 }
292 return t;
293 }
294 const body = cleanBody (pr.body);
295
296 // ---------- commits ----------
297 const commits = Array. isArray (pr.commits) ? pr.commits : [];
298 const commitLines = commits
299 . map (
300 ( c ) =>
301 c?.messageHeadline || (c?.messageBody || "" ). split ( " \n " )[ 0 ] || (c?.oid || "" ). slice ( 0 , 7 ),
302 )
303 . filter (Boolean);
304
305 // ---------- files (from pr.json) ----------
306 const files = (Array. isArray (pr.files) ? pr.files : []). map (( f ) => ({
307 path: f.path || f.filename || "" ,
308 additions: f.additions ?? 0 ,
309 deletions: f.deletions ?? 0 ,
310 }));
311
312 // ---------- parse the unified diff into per-file hunks ----------
313 function parseDiff ( raw ) {
314 if ( ! raw) return new Map ();
315 const lines = raw. split ( " \n " );
316 const byPath = new Map (); // path -> { hunks: string[][] }
317 let curPath = null ;
318 let curHunk = null ;
319 const ensure = ( p ) => {
320 if ( ! byPath. has (p)) byPath. set (p, { hunks: [] });
321 return byPath. get (p);
322 };
323 for ( const line of lines) {
324 if (line. startsWith ( "diff --git " )) {
325 // new file block; provisional path from "b/<path>" (refined by +++ below)
326 const m = / ^ diff --git a \/ ( . +? ) b \/ ( . + ) $ / . exec (line);
327 curPath = m ? m[ 2 ] : null ;
328 curHunk = null ;
329 if (curPath) ensure (curPath);
330 continue ;
331 }
332 if (line. startsWith ( "+++ " )) {
333 // authoritative new path ("+++ b/path" or "+++ /dev/null" for deletions)
334 const p = line. slice ( 4 ). replace ( / ^ b \/ / , "" ). trim ();
335 if (p && p !== "/dev/null" ) {
336 curPath = p;
337 ensure (curPath);
338 }
339 continue ;
340 }
341 if (line. startsWith ( "--- " )) continue ;
342 if (line. startsWith ( "@@" )) {
343 if ( ! curPath) continue ;
344 curHunk = [line];
345 ensure (curPath).hunks. push (curHunk);
346 continue ;
347 }
348 if (curHunk && curPath) {
349 // body line of the current hunk (context / + / -); ignore the trailing
350 // "\ No newline at end of file" sentinel
351 if (line. startsWith ( " \\ " )) continue ;
352 curHunk. push (line);
353 }
354 }
355 return byPath;
356 }
357 const diffByPath = parseDiff (diffRaw);
358
359 // Render a single hunk, trimmed: keep the @@ header + all +/- lines, but cap
360 // surrounding context to keep signal high and stay inside the line budget.
361 function renderHunk ( hunk ) {
362 const header = hunk[ 0 ];
363 const bodyLines = hunk. slice ( 1 );
364 const kept = [];
365 for ( const l of bodyLines) {
366 if (l. startsWith ( "+" ) || l. startsWith ( "-" )) kept. push (l);
367 else if (kept. length && kept[kept. length - 1 ] !== " ⋯" ) {
368 // collapse runs of context into a single marker (only between changes)
369 if (kept. some (( k ) => k. startsWith ( "+" ) || k. startsWith ( "-" ))) kept. push ( " ⋯" );
370 }
371 }
372 // drop a trailing context marker
373 while (kept. length && kept[kept. length - 1 ] === " ⋯" ) kept. pop ();
374 let out = [header. replace ( / \s *$ / , "" )];
375 out = out. concat (kept. slice ( 0 , MAX_HUNK_LINES ));
376 if (kept. length > MAX_HUNK_LINES )
377 out. push ( ` …(+${ kept . length - MAX_HUNK_LINES } more changed lines)` );
378 return out. join ( " \n " );
379 }
380
381 // ---------- rank files for the representative-diff section ----------
382 // real source first (non-noise, by total churn desc), noisy files last.
383 const ranked = [ ... files]
384 . filter (( f ) => f.path && diffByPath. has (f.path))
385 . sort (( a , b ) => {
386 const an = NOISE_RX . test (a.path) ? 1 : 0 ;
387 const bn = NOISE_RX . test (b.path) ? 1 : 0 ;
388 if (an !== bn) return an - bn;
389 return b.additions + b.deletions - (a.additions + a.deletions);
390 });
391 // include any diffed paths missing from files[] (rare; e.g. renames) at the tail
392 for ( const p of diffByPath. keys ()) {
393 if ( ! ranked. find (( f ) => f.path === p)) ranked. push ({ path: p, additions: 0 , deletions: 0 });
394 }
395
396 // ---------- build the representative-diff section under the char budget ----------
397 const diffSections = [];
398 let diffChars = 0 ;
399 let filesShown = 0 ;
400 let filesOmitted = 0 ;
401 for ( const f of ranked) {
402 const entry = diffByPath. get (f.path);
403 if ( ! entry || ! entry.hunks. length ) continue ;
404 const head = `### ${ f . path } (+${ f . additions } / -${ f . deletions })` ;
405 const rendered = entry.hunks. map (renderHunk). join ( " \n " );
406 const block = `${ head } \n ${ rendered }` ;
407 if (diffChars + block. length > MAX_DIFF_CHARS && filesShown > 0 ) {
408 filesOmitted ++ ;
409 continue ;
410 }
411 diffSections. push (block);
412 diffChars += block. length ;
413 filesShown ++ ;
414 }
415
416 // ---------- assemble visible-text.txt ----------
417 const lines = [];
418 lines. push ( `# ${ title }` );
419 lines. push ( "" );
420 const metaBits = [repo, `PR #${ number }` , `by ${ author }` ]. filter (Boolean);
421 lines. push (metaBits. join ( " · " ));
422 lines. push (
423 `${ baseRef } ← ${ headRef } · +${ additions } / -${ deletions } across ${ changedFiles } file(s)` ,
424 );
425 if (labels. length ) lines. push ( `Labels: ${ labels . join ( ", " ) }` );
426 if (url) lines. push ( `URL: ${ url }` );
427 if (shippedVersion)
428 lines. push ( `Shipped in: ${ shippedVersion }${ versionSource ? ` (${ versionSource })` : ""}` );
429 lines. push ( "" );
430
431 // People & reviews — human context for an optional credits / shipped-by close.
432 // Avatars land in assets/<login>.png (downloaded by the orchestrator). Each
433 // person is labeled "Name (@login)" — the credits close speaks the name, the
434 // handle is display-only (never read aloud; see story-design.md).
435 const label = ( p ) => (p.name ? `${ p . name } (@${ p . login })` : `@${ p . login }` );
436 if (people. length ) {
437 lines. push ( "## People & reviews" );
438 const authorPerson = people. find (( p ) => p.roles. includes ( "author" ));
439 if (authorPerson) lines. push ( `Author (opened PR): ${ label ( authorPerson ) }` );
440 const committers = people. filter (( p ) => p.roles. includes ( "committer" ));
441 if (committers. length ) {
442 const parts = committers
443 . slice ()
444 . sort (( a , b ) => b.commitCount - a.commitCount)
445 . map (
446 ( p ) =>
447 `${ label ( p ) }${ p . commitCount ? ` (${ p . commitCount } commit${ p . commitCount === 1 ? "" : "s"})` : ""}` ,
448 );
449 lines. push ( `Commit authors: ${ parts . join ( ", " ) }` );
450 }
451 const reviewers = people. filter (( p ) => p.roles. includes ( "reviewer" ));
452 if (reviewers. length ) {
453 const parts = reviewers. map (
454 ( p ) =>
455 `${ label ( p ) }${ p . reviewState ? ` (${ REVIEW_STATE_LABEL [ p . reviewState ] || p . reviewState . toLowerCase () })` : ""}` ,
456 );
457 lines. push ( `Reviewers: ${ parts . join ( ", " ) }` );
458 }
459 const commentersOnly = people. filter (
460 ( p ) =>
461 p.roles. includes ( "commenter" ) && ! p.roles. includes ( "author" ) && ! p.roles. includes ( "reviewer" ),
462 );
463 if (commentersOnly. length ) lines. push ( `Commenters: ${ commentersOnly . map ( label ). join ( ", " ) }` );
464 if (reviewDecision) lines. push ( `Review decision: ${ reviewDecision }` );
465 if (mergedByLogin) lines. push ( `Merged by: @${ mergedByLogin }` );
466 lines. push ( `Avatars: assets/<login>.png (${ people . length } contributor(s) — see people.json)` );
467 if (botsFiltered.size) lines. push ( `(bots filtered out: ${ [ ... botsFiltered ]. join ( ", " ) })` );
468 lines. push ( "" );
469 }
470
471 lines. push ( "## What the PR says" );
472 lines. push (body || "(no description provided)" );
473 lines. push ( "" );
474
475 if (commitLines. length ) {
476 lines. push ( `## Commits (${ commitLines . length })` );
477 for ( const c of commitLines. slice ( 0 , MAX_COMMITS )) lines. push ( `- ${ c }` );
478 if (commitLines. length > MAX_COMMITS )
479 lines. push ( `- …(+${ commitLines . length - MAX_COMMITS } more)` );
480 lines. push ( "" );
481 }
482
483 if (files. length ) {
484 lines. push ( `## Files changed (${ files . length })` );
485 const sortedFiles = [ ... files]. sort (
486 ( a , b ) => b.additions + b.deletions - (a.additions + a.deletions),
487 );
488 for ( const f of sortedFiles. slice ( 0 , MAX_FILES_LISTED )) {
489 lines. push ( `- ${ f . path } (+${ f . additions } / -${ f . deletions })` );
490 }
491 if (files. length > MAX_FILES_LISTED )
492 lines. push ( `- …(+${ files . length - MAX_FILES_LISTED } more files)` );
493 lines. push ( "" );
494 }
495
496 if (diffSections. length ) {
497 lines. push ( "## Representative diff" );
498 lines. push ( "" );
499 lines. push (diffSections. join ( " \n\n " ));
500 if (filesOmitted > 0 ) {
501 lines. push ( "" );
502 lines. push (
503 `…(diff truncated to fit; ${ filesOmitted } more changed file(s) omitted — see capture/diff.patch for the full change)` ,
504 );
505 }
506 lines. push ( "" );
507 } else if (diffRaw) {
508 lines. push ( "## Representative diff" );
509 lines. push ( "(diff present but no parseable hunks — see capture/diff.patch)" );
510 lines. push ( "" );
511 }
512
513 const visibleText =
514 lines
515 . join ( " \n " )
516 . replace ( / \n {3,} / g , " \n\n " )
517 . trim () + " \n " ;
518
519 // ---------- assemble tokens.json (FE scaffold shape; colors:[] → preset native palette) ----------
520 const oneLiner = (() => {
521 const firstPara = body. split ( " \n " ). find (( l ) => l. trim (). length > 0 ) || title;
522 const s = `PR #${ number }${ repo ? ` in ${ repo }` : ""}: ${ firstPara }` . replace ( / \s + / g , " " ). trim ();
523 return s. length > 150 ? s. slice ( 0 , 147 ). replace ( / \s + \S *$ / , "" ) + "…" : s;
524 })();
525 const tokens = {
526 title,
527 description: oneLiner,
528 colors: [],
529 fonts: [],
530 };
531
532 // ---------- assemble people.json ----------
533 const peopleDoc = {
534 authorLogin,
535 reviewDecision,
536 mergedBy: mergedByLogin,
537 botsFiltered: [ ... botsFiltered],
538 people, // deduped, bot-filtered; each has roles[] + avatarUrl + avatarFile + avatarFetched
539 };
540
541 // ---------- write ----------
542 mkdirSync (outDir, { recursive: true });
543 const tokensOut = join (outDir, "tokens.json" );
544 const textOut = join (outDir, "visible-text.txt" );
545 const peopleOut = join (outDir, "people.json" );
546 writeFileSync (tokensOut, JSON . stringify (tokens, null , 2 ) + " \n " );
547 writeFileSync (textOut, visibleText);
548 writeFileSync (peopleOut, JSON . stringify (peopleDoc, null , 2 ) + " \n " );
549
550 // ---------- summary ----------
551 const reviewerCount = people. filter (( p ) => p.roles. includes ( "reviewer" )). length ;
552 const committerCount = people. filter (( p ) => p.roles. includes ( "committer" )). length ;
553 console. log (
554 [
555 `✓ ingest: ${ repo || "(repo?)"} PR #${ number } — "${ title }"` ,
556 ` +${ additions } / -${ deletions } across ${ changedFiles } file(s); ${ commitLines . length } commit(s)` ,
557 ` diff: ${ filesShown } file(s) shown, ${ filesOmitted } omitted (budget ${ MAX_DIFF_CHARS } chars)` ,
558 ` people: ${ people . length } contributor(s) (${ committerCount } commit author(s), ${ reviewerCount } reviewer(s)${ reviewDecision ? `, decision ${ reviewDecision }` : ""}${ botsFiltered . size ? `; ${ botsFiltered . size } bot(s) filtered` : ""})` ,
559 ` wrote ${ textOut } (${ visibleText . length } chars) + ${ tokensOut } + ${ peopleOut }` ,
560 ]. join ( " \n " ),
561 );