@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.
- package/package.json +26 -0
- 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
|
+
})
|