Skip to content

Commit d9317f6

Browse files
feat: add weekly-metrics (#128)
Closes #122 Co-authored-by: Gar <[email protected]>
1 parent ffbae09 commit d9317f6

File tree

9 files changed

+294
-6
lines changed

9 files changed

+294
-6
lines changed

bin/gh.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ run({ render, ...argv })
192192
if (res == null) {
193193
return
194194
}
195-
const display = argv.template(res)
195+
const display = argv.template(res, argv)
196196
if (display != null) {
197197
render.output(display)
198198
}

lib/gh/index.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,12 @@ const run = async ({
6969
}
7070

7171
const confirmItems = query.template.default.confirm(items)
72+
const desc =
73+
typeof worker.args.desc === 'function'
74+
? worker.args.desc(workerData)
75+
: worker.args.desc
7276
let confirm = `Running worker "${worker.name}" which will `
73-
confirm += `"${worker.args.desc}" on the following items:\n${confirmItems}\n\n`
77+
confirm += `"${desc}" on the following items:\n${confirmItems}\n\n`
7478
confirm += `Press any key to continue or CTRL+C to exit`
7579

7680
render.log(confirm)

lib/gh/render/utils.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import stringWidth from 'string-width'
55

66
const OUTPUT_STREAM = 'stderr'
77

8+
const dateFormatter = new Intl.DateTimeFormat('en-US')
9+
10+
export const formatDate = (d) => dateFormatter.format(new Date(d))
11+
812
const rowify = (str, toWidth) => {
913
if (typeof toWidth !== 'number') {
1014
return str

lib/gh/templates/metrics.mjs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { WEEKLY_METRICS } from '../types.mjs'
2+
import { formatDate } from '../render/utils.mjs'
3+
4+
export const type = WEEKLY_METRICS
5+
6+
export const def = 'report'
7+
8+
const display = {
9+
prs: (merged, external, closed) =>
10+
`PRs: ${merged} merged (${external} external), ${closed} closed`,
11+
issues: (opened, closed) => `Issues: ${opened} opened, ${closed} closed`,
12+
releases: (releases) => `${releases} release${releases === 1 ? '' : 's'}`,
13+
}
14+
15+
export default {
16+
report: (rows, { date, ago, ...argv }) => {
17+
let mergedPrsTotal = 0
18+
let externalPrsTotal = 0
19+
let releasesTotal = 0
20+
let closedIssuesTotal = 0
21+
let openedIssuesTotal = 0
22+
let closedPrsTotal = 0
23+
24+
const repoDisplay = rows
25+
.map((row) => {
26+
const {
27+
mergedPrs,
28+
externalPrs,
29+
releases,
30+
closedIssues,
31+
openedIssues,
32+
closedPrs: _closedPrs,
33+
} = row.result.commands.at(-1).output
34+
35+
mergedPrsTotal += mergedPrs
36+
externalPrsTotal += externalPrs
37+
releasesTotal += releases
38+
closedIssuesTotal += closedIssues
39+
openedIssuesTotal += openedIssues
40+
const closedPrs = _closedPrs - mergedPrs
41+
closedPrsTotal += closedPrs
42+
43+
if (mergedPrs || releases || closedIssues || openedIssues) {
44+
const repo = [row.id]
45+
if (releases) {
46+
repo.push(display.releases(releases))
47+
}
48+
if (mergedPrs || closedPrs) {
49+
repo.push(display.prs(mergedPrs, externalPrs, closedPrs))
50+
}
51+
if (closedIssues || openedIssues) {
52+
repo.push(display.issues(openedIssues, closedIssues))
53+
}
54+
return repo.map((v, i) => (i === 0 ? v : ` ${v}`)).join('\n')
55+
}
56+
})
57+
.filter(Boolean)
58+
59+
const end = new Date(date)
60+
const start = new Date(end.getTime() - ago)
61+
return [
62+
...repoDisplay,
63+
`${argv.repos} activity from ${formatDate(start)} to ${formatDate(end)}`,
64+
`${rows.length} active repo${rows.length === 1 ? '' : 's'}`,
65+
display.releases(releasesTotal),
66+
display.prs(mergedPrsTotal, externalPrsTotal, closedPrsTotal),
67+
display.issues(openedIssuesTotal, closedIssuesTotal),
68+
].join('\n')
69+
},
70+
}

lib/gh/types.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export const PR = [OPEN_PR, CLOSED_PR]
55
export const WORKER = Symbol('WORKER')
66
export const QUERY = Symbol('QUERY')
77
export const LABEL = Symbol('LABEL')
8+
export const WEEKLY_METRICS = Symbol('WEEKLY_METRICS')

lib/gh/worker/thread.mjs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { join } from 'path'
2-
import { property, constant, isFunction, omit } from 'lodash-es'
2+
import { property, constant, isFunction, omit, identity } from 'lodash-es'
33
import sodium from 'libsodium-wrappers'
44
import { spawnSync, runDryCommand } from '../../utils.mjs'
55

@@ -13,10 +13,14 @@ const makeRun =
1313
{
1414
status: getStatus = property('status'),
1515
parse: parseResult,
16+
display: displayMessage = identity,
1617
...options
1718
} = {}
1819
) => {
19-
const message = { command: `${command} ${args.join(' ')}`, options }
20+
const message = {
21+
command: displayMessage(`${command} ${args.join(' ')}`),
22+
options,
23+
}
2024
postMessage({ command: message })
2125

2226
const spawnOptions = {

lib/gh/workers/metrics.mjs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { REPO, WEEKLY_METRICS } from '../types.mjs'
2+
import { safeJSONParse } from './_common.mjs'
3+
import { apiOnlyOptions } from '../yargs/utils.mjs'
4+
import { mapValues } from 'lodash-es'
5+
import { formatDate } from '../render/utils.mjs'
6+
7+
export const type = REPO
8+
9+
export const filter = []
10+
11+
export const template = WEEKLY_METRICS
12+
13+
const ONE_DAY = 1000 * 60 * 60 * 24
14+
15+
export const success = ({ result }) => {
16+
if (result?.output) {
17+
const prs = [
18+
result.output.mergedPrs,
19+
result.output.externalPrs,
20+
result.output.closedPrs,
21+
]
22+
const issues = [result.output.openedIssues, result.output.closedIssues]
23+
return `PRs:${prs.join('/')} Issues:${issues.join('/')} Releases:${
24+
result.output.releases
25+
}`
26+
}
27+
return ''
28+
}
29+
30+
export const args = {
31+
desc: ({ date, ago }) => {
32+
const end = new Date(date)
33+
const start = new Date(end.valueOf() - ago)
34+
return `Get metrics from ${formatDate(start)} to ${formatDate(end)}`
35+
},
36+
builder: (yargs) =>
37+
yargs.options({
38+
date: {
39+
alias: 'd',
40+
demand: true,
41+
desc: 'the end date',
42+
default: '',
43+
coerce: (v) => {
44+
const d = v
45+
? Number(new Date(v)) + ONE_DAY
46+
: Date.now() - (Date.now() % ONE_DAY)
47+
// Return a string because this will get serialized between workers anyway
48+
// so it will always end up as a string. This is just so we dont forget that.
49+
return new Date(d).toJSON()
50+
},
51+
},
52+
ago: {
53+
alias: 'a',
54+
demand: true,
55+
default: '7',
56+
desc: 'how many days back to go',
57+
coerce: (v) => ONE_DAY * (Number(v) - 1),
58+
},
59+
...apiOnlyOptions(),
60+
}),
61+
}
62+
63+
const setGhState = (name, dateKey, ghArgs) => [
64+
(o) => [
65+
'gh',
66+
ghArgs(o),
67+
{
68+
parse: (r) => safeJSONParse(r) ?? [],
69+
status: ({ status, stderr }) =>
70+
stderr.includes('repository has disabled issues') ? 0 : status,
71+
},
72+
],
73+
({ result, state, argv }) => {
74+
state[name] = result.output.filter((r) => {
75+
const delta = new Date(argv.date) - new Date(dateKey(r))
76+
return delta > 0 && delta < argv.ago
77+
})
78+
},
79+
]
80+
81+
export default [
82+
...setGhState(
83+
'mergedPrs',
84+
(r) => r.mergedAt,
85+
({ item }) => [
86+
'pr',
87+
'list',
88+
`--repo='${item.nameWithOwner}'`,
89+
'--state',
90+
'merged',
91+
'--json',
92+
'mergedAt,isCrossRepository',
93+
]
94+
),
95+
({ state }) => {
96+
state.externalPrs = state.mergedPrs.filter((r) => r.isCrossRepository)
97+
},
98+
// Ignoring bots makes the closed count wrong because it doesn't add back in merged bot PRs
99+
...setGhState(
100+
'closedPrs',
101+
(r) => r.closedAt,
102+
({ item }) => [
103+
'pr',
104+
'list',
105+
`--repo='${item.nameWithOwner}'`,
106+
'--state',
107+
'closed',
108+
'--json',
109+
'closedAt',
110+
]
111+
),
112+
...setGhState(
113+
'closedIssues',
114+
(r) => r.closedAt,
115+
({ item }) => [
116+
'issue',
117+
'list',
118+
`--repo='${item.nameWithOwner}'`,
119+
'--state',
120+
'closed',
121+
'--json',
122+
'closedAt',
123+
]
124+
),
125+
// We query all issues cause even closed ones may have been opened w/in this time window
126+
...setGhState(
127+
'openedIssues',
128+
(r) => r.createdAt,
129+
({ item }) => [
130+
'issue',
131+
'list',
132+
`--repo='${item.nameWithOwner}'`,
133+
'--json',
134+
'createdAt',
135+
]
136+
),
137+
...setGhState(
138+
'releases',
139+
(r) => r.published_at,
140+
({ item }) => ['api', `/repos/${item.nameWithOwner}/releases`]
141+
),
142+
({ state }) => [
143+
'echo',
144+
[`'${JSON.stringify(mapValues(state, (v) => v.length))}'`],
145+
{ parse: (r) => safeJSONParse(r) ?? {}, display: () => '' },
146+
],
147+
]

lib/gh/yargs/utils.mjs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,10 @@ export const getTemplateByQuery = (templates, query) => {
9191
// worker can be passed in here but currently all workers use the same
9292
// WORKER template. this can be changed in the future to match queries
9393
// when different workers need different templates
94-
export const getTemplateByWorker = (templates /* worker */) => {
95-
return templates.find((template) => hasIntersection(template.type, WORKER))
94+
export const getTemplateByWorker = (templates, worker) => {
95+
return templates.find((template) =>
96+
hasIntersection(template.type, worker.template ?? WORKER)
97+
)
9698
}
9799

98100
export const templateOptions = (choices) =>

tap-snapshots/test/gh.mjs.test.cjs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ gh graphql add-template-oss
2020
gh graphql clone
2121
gh graphql delete-branches
2222
gh graphql merge
23+
gh graphql metrics
2324
gh graphql pr-engines
2425
gh graphql publish-release
2526
gh graphql publish-repo
@@ -48,6 +49,7 @@ gh repos
4849
gh repos add-template-oss
4950
gh repos clone
5051
gh repos delete-branches
52+
gh repos metrics
5153
gh repos publish-repo
5254
gh repos pull
5355
gh repos repo-settings
@@ -502,6 +504,32 @@ Other Options:
502504
--config Path to JSON config file
503505
`
504506

507+
exports[`test/gh.mjs TAP all commands help graphql metrics > must match snapshot 1`] = `
508+
npx -p @npmcli/stafftools gh graphql metrics
509+
510+
Command Options:
511+
--query path to a query file passed directly to gh api graphql [string] [required]
512+
--cache how long for gh to cache the query [string] [default: "1m"]
513+
--report shorthand for --template=report [boolean] [default: false]
514+
-d, --date the end date [required] [default: ""]
515+
-a, --ago how many days back to go [required] [default: "7"]
516+
517+
Global Options:
518+
-c, --cwd base directory to run filesystem related commands [string] [default: null]
519+
-f, --filter filters to be parsed as relaxed json and applied to the data [array]
520+
-r, --reject rejectors to be parsed as relaxed json and applied to the data [array]
521+
--clean whether to rimraf the cwd first [boolean] [default: false]
522+
--template how to format the final output [string] [required] [choices: "json", "silent", "report"] [default: "report"]
523+
--sort key to sort results by [string] [default: "id"]
524+
--json shorthand for --template=json [boolean] [default: false]
525+
--silent shorthand for --template=silent [boolean] [default: false]
526+
527+
Other Options:
528+
--help Show help [boolean]
529+
--version Show version number [boolean]
530+
--config Path to JSON config file
531+
`
532+
505533
exports[`test/gh.mjs TAP all commands help graphql pr-engines > must match snapshot 1`] = `
506534
npx -p @npmcli/stafftools gh graphql pr-engines
507535
@@ -1370,6 +1398,34 @@ Other Options:
13701398
--config Path to JSON config file
13711399
`
13721400

1401+
exports[`test/gh.mjs TAP all commands help repos metrics > must match snapshot 1`] = `
1402+
npx -p @npmcli/stafftools gh repos metrics
1403+
1404+
Command Options:
1405+
--cache how long for gh to cache the query [string] [default: "1h"]
1406+
--repos query to filter repos [string] [required] [default: "org:npm topic:npm-cli fork:true archived:false"]
1407+
--table shorthand for --template=table [boolean] [default: false]
1408+
--confirm shorthand for --template=confirm [boolean] [default: false]
1409+
--report shorthand for --template=report [boolean] [default: false]
1410+
-d, --date the end date [required] [default: ""]
1411+
-a, --ago how many days back to go [required] [default: "7"]
1412+
1413+
Global Options:
1414+
-c, --cwd base directory to run filesystem related commands [string] [default: null]
1415+
-f, --filter filters to be parsed as relaxed json and applied to the data [array]
1416+
-r, --reject rejectors to be parsed as relaxed json and applied to the data [array]
1417+
--clean whether to rimraf the cwd first [boolean] [default: false]
1418+
--template how to format the final output [string] [required] [choices: "json", "silent", "table", "confirm", "report"] [default: "report"]
1419+
--sort key to sort results by [string] [default: "id"]
1420+
--json shorthand for --template=json [boolean] [default: false]
1421+
--silent shorthand for --template=silent [boolean] [default: false]
1422+
1423+
Other Options:
1424+
--help Show help [boolean]
1425+
--version Show version number [boolean]
1426+
--config Path to JSON config file
1427+
`
1428+
13731429
exports[`test/gh.mjs TAP all commands help repos publish-repo > must match snapshot 1`] = `
13741430
npx -p @npmcli/stafftools gh repos publish-repo
13751431

0 commit comments

Comments
 (0)