@0xbigboss/gh-pulse 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +26 -0
  2. package/src/index.ts +947 -0
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@0xbigboss/gh-pulse",
3
+ "version": "1.0.0",
4
+ "description": "GitHub analytics CLI for engineering visibility - PR metrics, contribution stats, team health",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/0xbigboss/gh-pulse"
8
+ },
9
+ "bin": {
10
+ "gh-pulse": "src/index.ts"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "type": "module",
16
+ "scripts": {
17
+ "build": "echo 'no build step'",
18
+ "start": "bun src/index.ts"
19
+ },
20
+ "engines": {
21
+ "bun": ">=1.0.0"
22
+ },
23
+ "dependencies": {
24
+ "@0xbigboss/gh-pulse-core": "^1.0.0"
25
+ }
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,947 @@
1
+ #!/usr/bin/env bun
2
+ import {
3
+ Cache,
4
+ GitHubClient,
5
+ loadConfig,
6
+ periodToRange,
7
+ resolveSince,
8
+ resolveUntil,
9
+ resolveRepos,
10
+ resolveTeamMembers,
11
+ syncRepos,
12
+ formatTimestamp,
13
+ } from '@0xbigboss/gh-pulse-core'
14
+ import type { ProgressEvent, Report, ReportInputs } from '@0xbigboss/gh-pulse-core'
15
+ import {
16
+ buildPersonalReport,
17
+ buildTeamReport,
18
+ buildExecReport,
19
+ buildReportMeta,
20
+ } from '@0xbigboss/gh-pulse-core'
21
+
22
+ const USAGE = `gh-pulse [options]
23
+
24
+ AUDIENCE
25
+ --for, -f <target> me | team | exec | @<username>
26
+ --period, -p <period> today | week | sprint | month | quarter
27
+ --since <date> Start date (ISO 8601 or relative: 7d, 2w, 1m)
28
+ --until <date> End date (ISO 8601 or relative)
29
+
30
+ SCOPE
31
+ --repos, -r <pattern> Repo filter (glob or comma-list)
32
+ --org, -o <org> GitHub org to scan
33
+
34
+ OUTPUT
35
+ --format <fmt> table | json | markdown
36
+ --sort <field> Sort by field
37
+ --quiet, -q Suppress progress output
38
+ --verbose, -v Show detailed progress
39
+
40
+ CONFIG
41
+ --config, -c <path> Config file path
42
+ --sync Force fresh sync (ignore cache TTL)
43
+
44
+ FILTERS
45
+ --blockers Focus on stale/stuck PRs only
46
+ --include-drafts Include draft PRs in metrics
47
+ `
48
+
49
+ type OutputFormat = 'table' | 'json' | 'markdown'
50
+
51
+ type ForTarget = 'me' | 'team' | 'exec' | string
52
+
53
+ type Period = 'today' | 'week' | 'sprint' | 'month' | 'quarter'
54
+
55
+ interface CliOptions {
56
+ forTarget: ForTarget
57
+ period: Period
58
+ format: OutputFormat
59
+ repos?: string
60
+ orgs?: string
61
+ since?: string
62
+ until?: string
63
+ configPath?: string
64
+ blockers: boolean
65
+ includeDrafts: boolean
66
+ sort?: string
67
+ quiet: boolean
68
+ verbose: boolean
69
+ sync: boolean
70
+ help: boolean
71
+ }
72
+
73
+ const DEFAULTS: CliOptions = {
74
+ forTarget: 'me',
75
+ period: 'week',
76
+ format: 'table',
77
+ blockers: false,
78
+ includeDrafts: false,
79
+ quiet: false,
80
+ verbose: false,
81
+ sync: false,
82
+ help: false,
83
+ }
84
+
85
+ async function main(): Promise<void> {
86
+ const args = parseArgs(process.argv.slice(2))
87
+ if (args.help) {
88
+ console.log(USAGE)
89
+ return
90
+ }
91
+ validateOptions(args)
92
+
93
+ const showProgress = !args.quiet && (args.verbose || Boolean(process.stderr.isTTY))
94
+ const onProgress = showProgress ? createProgressHandler({ verbose: args.verbose }) : undefined
95
+
96
+ const config = await loadConfig(args.configPath ? { path: args.configPath } : {})
97
+ const github = new GitHubClient({ token: config.github_token })
98
+
99
+ const target = await resolveTarget(args.forTarget, github)
100
+ const orgOverrides = args.orgs
101
+ ? args.orgs
102
+ .split(',')
103
+ .map((org) => org.trim())
104
+ .filter(Boolean)
105
+ : []
106
+ const repoPatterns = args.repos
107
+ ? args.repos
108
+ .split(',')
109
+ .map((repo) => repo.trim())
110
+ .filter(Boolean)
111
+ : []
112
+
113
+ const repos = await resolveRepos({
114
+ config,
115
+ github,
116
+ orgs: orgOverrides,
117
+ repoPatterns,
118
+ strict: true,
119
+ ...(onProgress ? { onProgress } : {}),
120
+ })
121
+
122
+ if (repos.length === 0) {
123
+ console.log('No repositories matched the current configuration.')
124
+ return
125
+ }
126
+
127
+ const now = new Date()
128
+ const timeRange = resolveTimeRange(args, config.timezone, now)
129
+
130
+ const cache = await Cache.open({ path: config.cache.path })
131
+ try {
132
+ await syncRepos({
133
+ repos,
134
+ cache,
135
+ github,
136
+ config,
137
+ timeRange,
138
+ forceSync: args.sync,
139
+ ...(onProgress ? { onProgress } : {}),
140
+ })
141
+
142
+ const teamMembers = await resolveTeamMembers({ config, github, repos, orgs: orgOverrides })
143
+ const excludeAuthors = new Set(config.exclude.authors)
144
+
145
+ const reportInputs: ReportInputs = {
146
+ repos,
147
+ timeRange,
148
+ includeDrafts: args.includeDrafts,
149
+ blockersOnly: args.blockers,
150
+ excludeAuthors,
151
+ excludeBots: config.exclude.bots,
152
+ teamMembers,
153
+ thresholds: {
154
+ stale_days: config.thresholds.stale_days,
155
+ stuck_days: config.thresholds.stuck_days,
156
+ large_pr_lines: config.thresholds.large_pr_lines ?? 500,
157
+ },
158
+ ...(target.user ? { subjectUser: target.user } : {}),
159
+ }
160
+
161
+ const report = buildReport(target, cache, reportInputs)
162
+ if (isEmptyPersonalReport(report)) {
163
+ console.log(`No activity found for @${report.user} in ${report.meta.period_label}`)
164
+ return
165
+ }
166
+
167
+ const sortedReport = applySort(report, args.sort)
168
+ const output = formatReport(
169
+ sortedReport,
170
+ args.format,
171
+ config.timezone,
172
+ args.blockers,
173
+ config.thresholds,
174
+ )
175
+ console.log(output)
176
+ } finally {
177
+ cache.close()
178
+ }
179
+ }
180
+
181
+ function parseArgs(argv: string[]): CliOptions {
182
+ const options: CliOptions = { ...DEFAULTS }
183
+
184
+ let index = 0
185
+ while (index < argv.length) {
186
+ const arg = argv[index]
187
+ if (!arg) {
188
+ break
189
+ }
190
+ if (arg === '--help' || arg === '-h') {
191
+ options.help = true
192
+ index += 1
193
+ continue
194
+ }
195
+
196
+ if (arg === '--blockers') {
197
+ options.blockers = true
198
+ index += 1
199
+ continue
200
+ }
201
+
202
+ if (arg === '--include-drafts') {
203
+ options.includeDrafts = true
204
+ index += 1
205
+ continue
206
+ }
207
+ if (arg === '--quiet' || arg === '-q') {
208
+ options.quiet = true
209
+ index += 1
210
+ continue
211
+ }
212
+ if (arg === '--verbose' || arg === '-v') {
213
+ options.verbose = true
214
+ index += 1
215
+ continue
216
+ }
217
+ if (arg === '--sync') {
218
+ options.sync = true
219
+ index += 1
220
+ continue
221
+ }
222
+
223
+ const [flag, inlineValue] =
224
+ arg.startsWith('--') && arg.includes('=') ? arg.split('=') : [arg, undefined]
225
+
226
+ if (inlineValue !== undefined) {
227
+ applyOption(options, flag, inlineValue)
228
+ index += 1
229
+ continue
230
+ }
231
+
232
+ const next = argv[index + 1]
233
+ if (!next || next.startsWith('-')) {
234
+ throw new Error(`Missing value for ${flag}`)
235
+ }
236
+
237
+ applyOption(options, flag, next)
238
+ index += 2
239
+ }
240
+
241
+ return options
242
+ }
243
+
244
+ function createProgressHandler(options: { verbose: boolean }): (event: ProgressEvent) => void {
245
+ return (event) => {
246
+ const progress =
247
+ typeof event.current === 'number' && typeof event.total === 'number'
248
+ ? ` (${event.current}/${event.total})`
249
+ : ''
250
+ const prefix = options.verbose ? `[${event.phase}] ` : ''
251
+ process.stderr.write(`${prefix}${event.message}${progress}\n`)
252
+ }
253
+ }
254
+
255
+ function applyOption(options: CliOptions, flag: string, value: string): void {
256
+ switch (flag) {
257
+ case '--for':
258
+ case '-f':
259
+ options.forTarget = value
260
+ return
261
+ case '--period':
262
+ case '-p':
263
+ options.period = value as Period
264
+ return
265
+ case '--format':
266
+ options.format = value as OutputFormat
267
+ return
268
+ case '--repos':
269
+ case '-r':
270
+ options.repos = value
271
+ return
272
+ case '--org':
273
+ case '-o':
274
+ options.orgs = value
275
+ return
276
+ case '--since':
277
+ options.since = value
278
+ return
279
+ case '--until':
280
+ options.until = value
281
+ return
282
+ case '--config':
283
+ case '-c':
284
+ options.configPath = value
285
+ return
286
+ case '--sort':
287
+ options.sort = value
288
+ return
289
+ default:
290
+ throw new Error(`Unknown argument: ${flag}`)
291
+ }
292
+ }
293
+
294
+ function validateOptions(options: CliOptions): void {
295
+ const validPeriods: Period[] = ['today', 'week', 'sprint', 'month', 'quarter']
296
+ if (!validPeriods.includes(options.period)) {
297
+ throw new Error(`Invalid period: ${options.period}`)
298
+ }
299
+
300
+ const validFormats: OutputFormat[] = ['table', 'json', 'markdown']
301
+ if (!validFormats.includes(options.format)) {
302
+ throw new Error(`Invalid format: ${options.format}`)
303
+ }
304
+ }
305
+
306
+ async function resolveTarget(
307
+ forTarget: ForTarget,
308
+ github: GitHubClient,
309
+ ): Promise<{
310
+ audience: 'personal' | 'individual' | 'team' | 'exec'
311
+ user?: string
312
+ }> {
313
+ if (forTarget === 'team') {
314
+ return { audience: 'team' }
315
+ }
316
+ if (forTarget === 'exec') {
317
+ return { audience: 'exec' }
318
+ }
319
+ if (forTarget === 'me') {
320
+ const user = await github.getAuthenticatedUsername()
321
+ return { audience: 'personal', user }
322
+ }
323
+
324
+ const user = forTarget.startsWith('@') ? forTarget.slice(1) : forTarget
325
+ const exists = await github.userExists(user)
326
+ if (!exists) {
327
+ throw new Error(`User not found: ${user}`)
328
+ }
329
+ return { audience: 'individual', user }
330
+ }
331
+
332
+ function resolveTimeRange(
333
+ options: CliOptions,
334
+ timeZone: string,
335
+ now: Date,
336
+ ): {
337
+ start: number
338
+ end: number
339
+ label: string
340
+ } {
341
+ const basePeriod = periodToRange(options.period, now, timeZone)
342
+
343
+ if (options.since || options.until) {
344
+ const start = options.since ? resolveSince(options.since, now) : basePeriod.start
345
+ const end = options.until ? resolveUntil(options.until, now) : now.getTime()
346
+ if (start > end) {
347
+ throw new Error('Invalid time range: start is after end')
348
+ }
349
+ const label = `custom ${options.since ?? 'start'} → ${options.until ?? 'now'}`
350
+ return { start, end, label }
351
+ }
352
+
353
+ return basePeriod
354
+ }
355
+
356
+ function buildReport(
357
+ target: { audience: 'personal' | 'individual' | 'team' | 'exec'; user?: string },
358
+ cache: Cache,
359
+ inputs: ReportInputs,
360
+ ): Report {
361
+ const meta = buildReportMeta({
362
+ audience: target.audience,
363
+ repos: inputs.repos,
364
+ periodLabel: inputs.timeRange.label,
365
+ start: inputs.timeRange.start,
366
+ end: inputs.timeRange.end,
367
+ cache,
368
+ })
369
+
370
+ switch (target.audience) {
371
+ case 'personal':
372
+ case 'individual':
373
+ if (!target.user) {
374
+ throw new Error('Missing user for personal report')
375
+ }
376
+ return buildPersonalReport(cache, {
377
+ ...inputs,
378
+ user: target.user,
379
+ meta,
380
+ })
381
+ case 'team':
382
+ return buildTeamReport(cache, { ...inputs, meta })
383
+ case 'exec':
384
+ return buildExecReport(cache, { ...inputs, meta })
385
+ default: {
386
+ const _exhaustive: never = target.audience
387
+ throw new Error(`Unhandled audience: ${_exhaustive}`)
388
+ }
389
+ }
390
+ }
391
+
392
+ function formatReport(
393
+ report: Report,
394
+ format: OutputFormat,
395
+ timeZone: string,
396
+ blockersOnly: boolean,
397
+ thresholds: { stale_days: number; stuck_days: number },
398
+ ): string {
399
+ if (format === 'json') {
400
+ return JSON.stringify(report, null, 2)
401
+ }
402
+
403
+ if (format === 'markdown') {
404
+ return formatMarkdown(report, timeZone, blockersOnly, thresholds)
405
+ }
406
+
407
+ return formatTable(report, timeZone, blockersOnly, thresholds)
408
+ }
409
+
410
+ function formatHeader(report: Report, timeZone: string): string[] {
411
+ const meta = report.meta
412
+ const lines: string[] = []
413
+ lines.push(`Audience: ${meta.audience}`)
414
+ lines.push(`Period: ${meta.period_label}`)
415
+ lines.push(
416
+ `Range: ${formatTimestamp(meta.start, timeZone)} → ${formatTimestamp(meta.end, timeZone)}`,
417
+ )
418
+ lines.push(`Repos: ${meta.repos.length}`)
419
+
420
+ const freshness = meta.data_freshness
421
+ if (freshness.dataAsOf) {
422
+ lines.push(`Data as of: ${formatTimestamp(freshness.dataAsOf, timeZone)}`)
423
+ }
424
+ if (freshness.cacheAgeMinutes !== null) {
425
+ lines.push(`Cache age: ${freshness.cacheAgeMinutes} minutes`)
426
+ }
427
+ lines.push(`Repos synced: ${freshness.reposSynced}/${freshness.totalRepos}`)
428
+ return lines
429
+ }
430
+
431
+ function formatTable(
432
+ report: Report,
433
+ timeZone: string,
434
+ blockersOnly: boolean,
435
+ thresholds: { stale_days: number; stuck_days: number },
436
+ ): string {
437
+ const sections: string[] = []
438
+ sections.push('gh-pulse report')
439
+ sections.push(...formatHeader(report, timeZone))
440
+ sections.push('')
441
+
442
+ switch (report.type) {
443
+ case 'personal':
444
+ case 'individual': {
445
+ if (blockersOnly) {
446
+ sections.push('Blockers')
447
+ const blockers = deriveBlockers(report, thresholds)
448
+ sections.push(
449
+ renderTable(
450
+ ['Repo', 'PR', 'Status', 'Days'],
451
+ blockers.map((b) => [b.repo, b.pr, b.status, `${b.days}`]),
452
+ ),
453
+ )
454
+ break
455
+ }
456
+
457
+ sections.push('Summary')
458
+ sections.push(
459
+ renderTable(
460
+ ['Metric', 'Value'],
461
+ [
462
+ ['PRs opened', `${report.summary.prs_opened}`],
463
+ ['PRs merged', `${report.summary.prs_merged}`],
464
+ ['PRs closed', `${report.summary.prs_closed}`],
465
+ ['Commits', `${report.summary.commits}`],
466
+ ['Repos touched', `${report.summary.repos_touched}`],
467
+ ['Lines changed', `${report.summary.lines_changed}`],
468
+ ],
469
+ ),
470
+ )
471
+
472
+ sections.push('Open Work')
473
+ sections.push(
474
+ renderTable(
475
+ ['Repo', 'PR', 'Age (days)', 'Title'],
476
+ report.open_work.open_prs.map((pr) => [
477
+ pr.repo,
478
+ `#${pr.number}`,
479
+ `${pr.age_days}`,
480
+ pr.title,
481
+ ]),
482
+ ),
483
+ )
484
+
485
+ sections.push('Review Debt - Requested')
486
+ sections.push(
487
+ renderTable(
488
+ ['Repo', 'PR', 'Author', 'Age (days)', 'Title'],
489
+ report.review_debt.requested.map((pr) => [
490
+ pr.repo,
491
+ `#${pr.number}`,
492
+ pr.author,
493
+ `${pr.age_days}`,
494
+ pr.title,
495
+ ]),
496
+ ),
497
+ )
498
+
499
+ sections.push('Review Debt - Team PRs')
500
+ sections.push(
501
+ renderTable(
502
+ ['Repo', 'PR', 'Author', 'Age (days)', 'Title'],
503
+ report.review_debt.team_prs.map((pr) => [
504
+ pr.repo,
505
+ `#${pr.number}`,
506
+ pr.author,
507
+ `${pr.age_days}`,
508
+ pr.title,
509
+ ]),
510
+ ),
511
+ )
512
+ break
513
+ }
514
+ case 'team': {
515
+ if (blockersOnly) {
516
+ sections.push('Blockers')
517
+ sections.push(
518
+ renderTable(
519
+ ['Repo', 'PR', 'Owner', 'Status', 'Days'],
520
+ report.blockers.map((blocker) => [
521
+ blocker.pr.repo,
522
+ `#${blocker.pr.number}`,
523
+ blocker.pr.author,
524
+ blocker.status,
525
+ `${blocker.days}`,
526
+ ]),
527
+ ),
528
+ )
529
+ break
530
+ }
531
+
532
+ sections.push('Velocity')
533
+ sections.push(
534
+ renderTable(
535
+ ['Metric', 'Value'],
536
+ [
537
+ ['PRs opened', `${report.velocity.prs_opened}`],
538
+ ['PRs merged', `${report.velocity.prs_merged}`],
539
+ ['PRs closed', `${report.velocity.prs_closed}`],
540
+ ['Cycle time p50 (ms)', `${report.velocity.cycle_time_p50 ?? 'n/a'}`],
541
+ ['Cycle time p90 (ms)', `${report.velocity.cycle_time_p90 ?? 'n/a'}`],
542
+ ['Review turnaround p50 (ms)', `${report.velocity.review_turnaround_p50 ?? 'n/a'}`],
543
+ ['Review turnaround p90 (ms)', `${report.velocity.review_turnaround_p90 ?? 'n/a'}`],
544
+ ],
545
+ ),
546
+ )
547
+
548
+ sections.push('Member Breakdown')
549
+ sections.push(
550
+ renderTable(
551
+ ['User', 'PRs authored', 'PRs reviewed', 'Commits', 'Lines changed'],
552
+ report.members.map((member) => [
553
+ member.user,
554
+ `${member.prs_authored}`,
555
+ `${member.prs_reviewed}`,
556
+ `${member.commits}`,
557
+ `${member.lines_changed}`,
558
+ ]),
559
+ ),
560
+ )
561
+
562
+ sections.push('Blockers')
563
+ sections.push(
564
+ renderTable(
565
+ ['Repo', 'PR', 'Owner', 'Status', 'Days'],
566
+ report.blockers.map((blocker) => [
567
+ blocker.pr.repo,
568
+ `#${blocker.pr.number}`,
569
+ blocker.pr.author,
570
+ blocker.status,
571
+ `${blocker.days}`,
572
+ ]),
573
+ ),
574
+ )
575
+ break
576
+ }
577
+ case 'exec': {
578
+ sections.push('Velocity Trends')
579
+ sections.push(
580
+ renderTable(
581
+ ['Week start', 'PRs merged', 'Cycle time p50 (ms)'],
582
+ report.velocity_trends.map((trend) => [
583
+ formatTimestamp(trend.bucket_start, timeZone),
584
+ `${trend.prs_merged}`,
585
+ `${trend.cycle_time_p50 ?? 'n/a'}`,
586
+ ]),
587
+ ),
588
+ )
589
+
590
+ sections.push('Repo Health')
591
+ sections.push(
592
+ renderTable(
593
+ ['Repo', 'Status', 'Last activity'],
594
+ report.repo_health.map((entry) => [
595
+ entry.repo,
596
+ entry.status,
597
+ entry.last_activity_at ? formatTimestamp(entry.last_activity_at, timeZone) : 'n/a',
598
+ ]),
599
+ ),
600
+ )
601
+
602
+ sections.push('Highlights')
603
+ sections.push(
604
+ renderTable(
605
+ ['Repo', 'PR', 'Reasons'],
606
+ report.highlights.map((entry) => [
607
+ entry.pr.repo,
608
+ `#${entry.pr.number}`,
609
+ entry.reasons.join('; '),
610
+ ]),
611
+ ),
612
+ )
613
+
614
+ sections.push('Team Breakdown')
615
+ sections.push(
616
+ renderTable(
617
+ ['User', 'PRs authored', 'PRs reviewed', 'Commits', 'Lines changed'],
618
+ report.team_breakdown.map((member) => [
619
+ member.user,
620
+ `${member.prs_authored}`,
621
+ `${member.prs_reviewed}`,
622
+ `${member.commits}`,
623
+ `${member.lines_changed}`,
624
+ ]),
625
+ ),
626
+ )
627
+ break
628
+ }
629
+ default: {
630
+ const _exhaustive: never = report
631
+ throw new Error(`Unhandled report type: ${_exhaustive}`)
632
+ }
633
+ }
634
+
635
+ return sections.join('\n')
636
+ }
637
+
638
+ function formatMarkdown(
639
+ report: Report,
640
+ timeZone: string,
641
+ blockersOnly: boolean,
642
+ thresholds: { stale_days: number; stuck_days: number },
643
+ ): string {
644
+ const lines: string[] = []
645
+ lines.push(`# gh-pulse ${report.type} report`)
646
+ lines.push('')
647
+ formatHeader(report, timeZone).forEach((line) => lines.push(`- ${line}`))
648
+ lines.push('')
649
+
650
+ const renderMarkdownTable = (headers: string[], rows: string[][]): void => {
651
+ lines.push(`| ${headers.join(' | ')} |`)
652
+ lines.push(`| ${headers.map(() => '---').join(' | ')} |`)
653
+ rows.forEach((row) => lines.push(`| ${row.join(' | ')} |`))
654
+ lines.push('')
655
+ }
656
+
657
+ switch (report.type) {
658
+ case 'personal':
659
+ case 'individual': {
660
+ if (blockersOnly) {
661
+ lines.push('## Blockers')
662
+ const blockers = deriveBlockers(report, thresholds)
663
+ renderMarkdownTable(
664
+ ['Repo', 'PR', 'Status', 'Days'],
665
+ blockers.map((b) => [b.repo, b.pr, b.status, `${b.days}`]),
666
+ )
667
+ break
668
+ }
669
+
670
+ lines.push('## Summary')
671
+ renderMarkdownTable(
672
+ ['Metric', 'Value'],
673
+ [
674
+ ['PRs opened', `${report.summary.prs_opened}`],
675
+ ['PRs merged', `${report.summary.prs_merged}`],
676
+ ['PRs closed', `${report.summary.prs_closed}`],
677
+ ['Commits', `${report.summary.commits}`],
678
+ ['Repos touched', `${report.summary.repos_touched}`],
679
+ ['Lines changed', `${report.summary.lines_changed}`],
680
+ ],
681
+ )
682
+
683
+ lines.push('## Open Work')
684
+ renderMarkdownTable(
685
+ ['Repo', 'PR', 'Age (days)', 'Title'],
686
+ report.open_work.open_prs.map((pr) => [
687
+ pr.repo,
688
+ `#${pr.number}`,
689
+ `${pr.age_days}`,
690
+ pr.title,
691
+ ]),
692
+ )
693
+
694
+ lines.push('## Review Debt - Requested')
695
+ renderMarkdownTable(
696
+ ['Repo', 'PR', 'Author', 'Age (days)', 'Title'],
697
+ report.review_debt.requested.map((pr) => [
698
+ pr.repo,
699
+ `#${pr.number}`,
700
+ pr.author,
701
+ `${pr.age_days}`,
702
+ pr.title,
703
+ ]),
704
+ )
705
+
706
+ lines.push('## Review Debt - Team PRs')
707
+ renderMarkdownTable(
708
+ ['Repo', 'PR', 'Author', 'Age (days)', 'Title'],
709
+ report.review_debt.team_prs.map((pr) => [
710
+ pr.repo,
711
+ `#${pr.number}`,
712
+ pr.author,
713
+ `${pr.age_days}`,
714
+ pr.title,
715
+ ]),
716
+ )
717
+ break
718
+ }
719
+ case 'team': {
720
+ if (blockersOnly) {
721
+ lines.push('## Blockers')
722
+ renderMarkdownTable(
723
+ ['Repo', 'PR', 'Owner', 'Status', 'Days'],
724
+ report.blockers.map((blocker) => [
725
+ blocker.pr.repo,
726
+ `#${blocker.pr.number}`,
727
+ blocker.pr.author,
728
+ blocker.status,
729
+ `${blocker.days}`,
730
+ ]),
731
+ )
732
+ break
733
+ }
734
+
735
+ lines.push('## Velocity')
736
+ renderMarkdownTable(
737
+ ['Metric', 'Value'],
738
+ [
739
+ ['PRs opened', `${report.velocity.prs_opened}`],
740
+ ['PRs merged', `${report.velocity.prs_merged}`],
741
+ ['PRs closed', `${report.velocity.prs_closed}`],
742
+ ['Cycle time p50 (ms)', `${report.velocity.cycle_time_p50 ?? 'n/a'}`],
743
+ ['Cycle time p90 (ms)', `${report.velocity.cycle_time_p90 ?? 'n/a'}`],
744
+ ['Review turnaround p50 (ms)', `${report.velocity.review_turnaround_p50 ?? 'n/a'}`],
745
+ ['Review turnaround p90 (ms)', `${report.velocity.review_turnaround_p90 ?? 'n/a'}`],
746
+ ],
747
+ )
748
+
749
+ lines.push('## Member Breakdown')
750
+ renderMarkdownTable(
751
+ ['User', 'PRs authored', 'PRs reviewed', 'Commits', 'Lines changed'],
752
+ report.members.map((member) => [
753
+ member.user,
754
+ `${member.prs_authored}`,
755
+ `${member.prs_reviewed}`,
756
+ `${member.commits}`,
757
+ `${member.lines_changed}`,
758
+ ]),
759
+ )
760
+
761
+ lines.push('## Blockers')
762
+ renderMarkdownTable(
763
+ ['Repo', 'PR', 'Owner', 'Status', 'Days'],
764
+ report.blockers.map((blocker) => [
765
+ blocker.pr.repo,
766
+ `#${blocker.pr.number}`,
767
+ blocker.pr.author,
768
+ blocker.status,
769
+ `${blocker.days}`,
770
+ ]),
771
+ )
772
+ break
773
+ }
774
+ case 'exec': {
775
+ lines.push('## Velocity Trends')
776
+ renderMarkdownTable(
777
+ ['Week start', 'PRs merged', 'Cycle time p50 (ms)'],
778
+ report.velocity_trends.map((trend) => [
779
+ formatTimestamp(trend.bucket_start, timeZone),
780
+ `${trend.prs_merged}`,
781
+ `${trend.cycle_time_p50 ?? 'n/a'}`,
782
+ ]),
783
+ )
784
+
785
+ lines.push('## Repo Health')
786
+ renderMarkdownTable(
787
+ ['Repo', 'Status', 'Last activity'],
788
+ report.repo_health.map((entry) => [
789
+ entry.repo,
790
+ entry.status,
791
+ entry.last_activity_at ? formatTimestamp(entry.last_activity_at, timeZone) : 'n/a',
792
+ ]),
793
+ )
794
+
795
+ lines.push('## Highlights')
796
+ renderMarkdownTable(
797
+ ['Repo', 'PR', 'Reasons'],
798
+ report.highlights.map((entry) => [
799
+ entry.pr.repo,
800
+ `#${entry.pr.number}`,
801
+ entry.reasons.join('; '),
802
+ ]),
803
+ )
804
+
805
+ lines.push('## Team Breakdown')
806
+ renderMarkdownTable(
807
+ ['User', 'PRs authored', 'PRs reviewed', 'Commits', 'Lines changed'],
808
+ report.team_breakdown.map((member) => [
809
+ member.user,
810
+ `${member.prs_authored}`,
811
+ `${member.prs_reviewed}`,
812
+ `${member.commits}`,
813
+ `${member.lines_changed}`,
814
+ ]),
815
+ )
816
+ break
817
+ }
818
+ default: {
819
+ const _exhaustive: never = report
820
+ throw new Error(`Unhandled report type: ${_exhaustive}`)
821
+ }
822
+ }
823
+
824
+ return lines.join('\n')
825
+ }
826
+
827
+ function renderTable(headers: string[], rows: string[][]): string {
828
+ if (rows.length === 0) {
829
+ return `${headers.join(' | ')}\n${headers.map(() => '---').join(' | ')}\n(no rows)`
830
+ }
831
+
832
+ const widths = headers.map((header, index) =>
833
+ Math.max(header.length, ...rows.map((row) => (row[index] ?? '').length)),
834
+ )
835
+ const formatRow = (row: string[]): string =>
836
+ row.map((cell, index) => cell.padEnd(widths[index] ?? 0, ' ')).join(' | ')
837
+
838
+ const headerRow = formatRow(headers)
839
+ const separator = widths.map((width) => '-'.repeat(width ?? 0)).join('-|-')
840
+ const body = rows.map(formatRow)
841
+ return [headerRow, separator, ...body].join('\n')
842
+ }
843
+
844
+ type BlockerRow = { repo: string; pr: string; status: string; days: number }
845
+
846
+ function applySort(report: Report, sortField?: string): Report {
847
+ if (!sortField) {
848
+ return report
849
+ }
850
+
851
+ const sort = sortField.trim().toLowerCase()
852
+
853
+ if (report.type === 'team') {
854
+ return {
855
+ ...report,
856
+ members: sortMembers(report.members, sort),
857
+ }
858
+ }
859
+
860
+ if (report.type === 'exec') {
861
+ return {
862
+ ...report,
863
+ team_breakdown: sortMembers(report.team_breakdown, sort),
864
+ }
865
+ }
866
+
867
+ return report
868
+ }
869
+
870
+ function sortMembers(
871
+ members: Array<{
872
+ user: string
873
+ prs_authored: number
874
+ prs_reviewed: number
875
+ commits: number
876
+ lines_changed: number
877
+ }>,
878
+ sort: string,
879
+ ): Array<{
880
+ user: string
881
+ prs_authored: number
882
+ prs_reviewed: number
883
+ commits: number
884
+ lines_changed: number
885
+ }> {
886
+ switch (sort) {
887
+ case 'user':
888
+ case 'author':
889
+ return [...members].toSorted((a, b) => a.user.localeCompare(b.user))
890
+ case 'prs_authored':
891
+ return [...members].toSorted((a, b) => b.prs_authored - a.prs_authored)
892
+ case 'prs_reviewed':
893
+ return [...members].toSorted((a, b) => b.prs_reviewed - a.prs_reviewed)
894
+ case 'commits':
895
+ return [...members].toSorted((a, b) => b.commits - a.commits)
896
+ case 'lines_changed':
897
+ return [...members].toSorted((a, b) => b.lines_changed - a.lines_changed)
898
+ default:
899
+ return members
900
+ }
901
+ }
902
+
903
+ function deriveBlockers(
904
+ report: Extract<Report, { type: 'personal' | 'individual' }>,
905
+ thresholds: { stale_days: number; stuck_days: number },
906
+ ): BlockerRow[] {
907
+ const staleDays = thresholds.stale_days
908
+ const stuckDays = thresholds.stuck_days
909
+ return report.open_work.open_prs.flatMap((pr) => {
910
+ const blockers: BlockerRow[] = []
911
+ const stale = Math.floor((Date.now() - pr.updated_at) / (24 * 60 * 60 * 1000))
912
+ const stuck = Math.floor((Date.now() - pr.created_at) / (24 * 60 * 60 * 1000))
913
+ if (stale >= staleDays) {
914
+ blockers.push({ repo: pr.repo, pr: `#${pr.number}`, status: 'stale', days: stale })
915
+ }
916
+ if (stuck >= stuckDays) {
917
+ blockers.push({ repo: pr.repo, pr: `#${pr.number}`, status: 'stuck', days: stuck })
918
+ }
919
+ return blockers
920
+ })
921
+ }
922
+
923
+ function isEmptyPersonalReport(
924
+ report: Report,
925
+ ): report is Extract<Report, { type: 'personal' | 'individual' }> {
926
+ if (report.type !== 'personal' && report.type !== 'individual') {
927
+ return false
928
+ }
929
+
930
+ const summary = report.summary
931
+ const hasSummary =
932
+ summary.prs_opened > 0 ||
933
+ summary.prs_merged > 0 ||
934
+ summary.prs_closed > 0 ||
935
+ summary.commits > 0
936
+
937
+ const hasOpenWork = report.open_work.open_prs.length > 0
938
+ const hasReviewDebt =
939
+ report.review_debt.requested.length > 0 || report.review_debt.team_prs.length > 0
940
+
941
+ return !(hasSummary || hasOpenWork || hasReviewDebt)
942
+ }
943
+
944
+ main().catch((error) => {
945
+ console.error(`gh-pulse error: ${error instanceof Error ? error.message : String(error)}`)
946
+ process.exit(1)
947
+ })