@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.46

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 (207) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +25 -2
  5. package/common/default-scan-models.ts +1 -1
  6. package/common/dependency-reviews.d.ts +0 -5
  7. package/common/dependency-reviews.js +0 -17
  8. package/common/line-diff.js +5 -4
  9. package/common/managed/agent-tasks.ts +292 -0
  10. package/common/managed/github-agents.ts +92 -0
  11. package/common/managed/github-logins.ts +49 -0
  12. package/common/managed/roles.ts +7 -0
  13. package/common/managed/routes.js +88 -7
  14. package/common/managed/team-app.d.ts +5 -2
  15. package/common/managed/team-app.js +2 -2
  16. package/common/server-info.ts +3 -0
  17. package/common/workspace-app.js +6 -3
  18. package/out/brotli-fallback.js +3 -3
  19. package/out/chunk-2XUEZHIP.js +18 -18
  20. package/out/chunk-2ZDZWLFY.js +1 -0
  21. package/out/chunk-3PS4KWD2.js +10 -0
  22. package/out/chunk-4HI2MSAV.js +17 -0
  23. package/out/chunk-4UFAMJAX.js +1 -0
  24. package/out/chunk-7C7DWL7I.js +1 -0
  25. package/out/chunk-7XRR4OFU.js +1 -0
  26. package/out/chunk-BC2UCLFJ.js +1 -0
  27. package/out/chunk-BOIXMUU5.js +3 -0
  28. package/out/chunk-C4Z6ZBXV.js +6 -6
  29. package/out/chunk-CC4OM6TZ.js +3 -0
  30. package/out/chunk-G24A42V6.js +2 -0
  31. package/out/chunk-GDPCLSOS.js +2 -0
  32. package/out/chunk-GO7FT4OD.js +1 -0
  33. package/out/chunk-I2MDGSQX.js +3 -0
  34. package/out/chunk-IYPY47MC.js +1 -0
  35. package/out/chunk-IZ7POWI3.js +18 -0
  36. package/out/chunk-JCIIBLKR.js +1 -1
  37. package/out/chunk-JKGQ3O7O.js +6 -0
  38. package/out/chunk-LE5ZWLG4.js +235 -0
  39. package/out/chunk-MSION3G4.js +36 -0
  40. package/out/chunk-NRPC74AM.js +1 -1
  41. package/out/chunk-OKRLLE5I.js +17 -0
  42. package/out/chunk-OOJM4CTU.js +1 -1
  43. package/out/chunk-PNHBZGDD.js +1 -1
  44. package/out/chunk-PXK7EU4V.js +1 -0
  45. package/out/chunk-QB4CBOX2.js +1 -0
  46. package/out/chunk-R3NZFXVR.js +1 -0
  47. package/out/chunk-R5O6FAW4.js +1 -1
  48. package/out/chunk-S6GIXWQW.js +52 -0
  49. package/out/chunk-SXV2T5C4.js +80 -0
  50. package/out/chunk-TUII34K2.js +1 -1
  51. package/out/chunk-V3KWNNMY.js +1 -0
  52. package/out/chunk-VFWMQ3LG.js +2 -0
  53. package/out/chunk-VH4XMZIW.js +2 -0
  54. package/out/chunk-WFYLMF6Z.js +10 -0
  55. package/out/chunk-WQ74ZBZF.js +7 -0
  56. package/out/client-managed-import.js +3 -3
  57. package/out/client-managed.js +31 -35
  58. package/out/client-sync.js +11 -11
  59. package/out/compare.js +35 -0
  60. package/out/dialogs.js +51 -0
  61. package/out/github-issue-authorized.html +1 -1
  62. package/out/github-issue-authorized.js +1 -1
  63. package/out/graph.js +17 -17
  64. package/out/index.html +30 -34
  65. package/out/prism.js +14 -14
  66. package/out/scan.js +2 -0
  67. package/out/terminal.js +294 -293
  68. package/out/view.css +1 -1
  69. package/out/view.js +16 -107
  70. package/package.json +53 -6
  71. package/risk/actions/findings.js +161 -0
  72. package/risk/actions/resolve.js +219 -0
  73. package/risk/actions/scripts/commands.js +182 -0
  74. package/risk/actions/scripts/grade.js +113 -0
  75. package/risk/actions/scripts/packages.js +123 -0
  76. package/risk/actions/scripts/shell.js +169 -0
  77. package/risk/actions/uses.js +76 -0
  78. package/risk/actions/workflow.js +110 -0
  79. package/risk/files/hosts.js +6 -6
  80. package/risk/files/readability.js +23 -17
  81. package/risk/files/runs-code.js +18 -6
  82. package/risk/files/script.js +7 -3
  83. package/risk/files/stamps.js +8 -7
  84. package/risk/index.d.ts +71 -1
  85. package/risk/index.js +6 -2
  86. package/risk/npm/assess.js +1 -1
  87. package/risk/npm/compare.js +34 -10
  88. package/risk/npm/files.js +413 -2
  89. package/risk/npm/tarball.js +56 -73
  90. package/server-e2e/app.ts +5 -4
  91. package/server-e2e/http.ts +5 -2
  92. package/server-managed/agent-merges.ts +198 -0
  93. package/server-managed/app-agents.ts +30 -0
  94. package/server-managed/avatar-store.ts +1 -0
  95. package/server-managed/bundle-cache.ts +7 -1
  96. package/server-managed/combined.ts +3 -2
  97. package/server-managed/commit-coauthors.ts +83 -0
  98. package/server-managed/commit-lockfiles.ts +72 -0
  99. package/server-managed/config.ts +6 -0
  100. package/server-managed/db-methods.ts +66 -19
  101. package/server-managed/db-neon.ts +89 -21
  102. package/server-managed/db-schema.ts +13 -2
  103. package/server-managed/db.ts +9 -1
  104. package/server-managed/dependabot-access.ts +60 -0
  105. package/server-managed/dependabot-alerts.ts +185 -0
  106. package/server-managed/dependency-review-comments.ts +97 -0
  107. package/server-managed/dependency-reviews.ts +78 -18
  108. package/server-managed/github-accounts.ts +33 -0
  109. package/server-managed/github-actions.ts +71 -0
  110. package/server-managed/github-app.ts +33 -13
  111. package/server-managed/github-errors.ts +2 -0
  112. package/server-managed/github-issue-links.ts +11 -5
  113. package/server-managed/github-link-oauth.ts +69 -0
  114. package/server-managed/github-links.ts +204 -0
  115. package/server-managed/github-oauth.ts +5 -2
  116. package/server-managed/github-pulls.ts +2 -2
  117. package/server-managed/github-requests.ts +39 -0
  118. package/server-managed/github-users.ts +30 -0
  119. package/server-managed/http-response.ts +10 -2
  120. package/server-managed/http.ts +1251 -97
  121. package/server-managed/index.ts +13 -8
  122. package/server-managed/kept-briefly.ts +37 -0
  123. package/server-managed/locked-packages.ts +37 -16
  124. package/server-managed/lockfile-scope.ts +49 -0
  125. package/server-managed/npm-delta-worker.js +23 -0
  126. package/server-managed/npm-delta.ts +178 -0
  127. package/server-managed/npm-insights.ts +20 -11
  128. package/server-managed/npm-kept.ts +83 -6
  129. package/server-managed/npm-loads.ts +114 -37
  130. package/server-managed/npm-packages.ts +110 -64
  131. package/server-managed/npm-risk.ts +4 -1
  132. package/server-managed/pretty-print.ts +67 -23
  133. package/server-managed/pull-comment-reviews.ts +161 -0
  134. package/server-managed/pull-dependencies.ts +300 -0
  135. package/server-managed/pull-dependency-reviews.ts +318 -0
  136. package/server-managed/pull-reviews.ts +205 -0
  137. package/server-managed/queue.ts +16 -0
  138. package/server-managed/release-commits.ts +84 -0
  139. package/server-managed/rename-diff.ts +22 -13
  140. package/server-managed/report-sources.ts +1 -1
  141. package/server-managed/report-store.ts +42 -9
  142. package/server-managed/repository-actions.ts +218 -0
  143. package/server-managed/repository-agents.ts +220 -0
  144. package/server-managed/repository-browser.ts +166 -37
  145. package/server-managed/repository-changes.ts +284 -0
  146. package/server-managed/repository-code.ts +52 -0
  147. package/server-managed/repository-dependency-reviews.ts +157 -0
  148. package/server-managed/repository-history.ts +461 -0
  149. package/server-managed/repository-issues.ts +181 -0
  150. package/server-managed/repository-pulse.ts +439 -0
  151. package/server-managed/repository-risk-audit.ts +13 -0
  152. package/server-managed/repository-risk-read.ts +170 -0
  153. package/server-managed/repository-risk.ts +133 -118
  154. package/server-managed/reviewed-dependencies.ts +149 -0
  155. package/server-managed/session.ts +27 -9
  156. package/server-managed/sql.ts +6 -3
  157. package/server-managed/storage-stores.ts +5 -0
  158. package/server-managed/storage.ts +4 -2
  159. package/server-managed/team-app.ts +19 -3
  160. package/server-managed/team-reports.ts +24 -12
  161. package/server-managed/upstream-cache.ts +12 -0
  162. package/server-managed/workspace-shares.ts +4 -1
  163. package/server.ts +25 -5
  164. package/out/chunk-2ABLKCOY.js +0 -46
  165. package/out/chunk-2KBF5TZ7.js +0 -2
  166. package/out/chunk-35VE6CDH.js +0 -2
  167. package/out/chunk-3BZ7JERB.js +0 -1
  168. package/out/chunk-4XCDFGDP.js +0 -10
  169. package/out/chunk-5LSXOLUS.js +0 -1
  170. package/out/chunk-5RCM2J63.js +0 -1
  171. package/out/chunk-7B6HB4D7.js +0 -1
  172. package/out/chunk-AJ4GTATZ.js +0 -3
  173. package/out/chunk-CH4UN6WK.js +0 -225
  174. package/out/chunk-CMQA4DM6.js +0 -1
  175. package/out/chunk-CVVJAXVY.js +0 -2
  176. package/out/chunk-DIJJNTRH.js +0 -10
  177. package/out/chunk-EDKLBQQN.js +0 -17
  178. package/out/chunk-F47DXCEV.js +0 -33
  179. package/out/chunk-FK3DZEKL.js +0 -1
  180. package/out/chunk-G6AJWENO.js +0 -1
  181. package/out/chunk-GJ4IUCNS.js +0 -1
  182. package/out/chunk-GNXOKIGP.js +0 -80
  183. package/out/chunk-GZ4MS3TE.js +0 -13
  184. package/out/chunk-GZWM7UQM.js +0 -2
  185. package/out/chunk-IU3CCINR.js +0 -3
  186. package/out/chunk-K3X5EJBQ.js +0 -2
  187. package/out/chunk-KEX2ATEW.js +0 -2
  188. package/out/chunk-KYCNJVB7.js +0 -1
  189. package/out/chunk-LPCTJYNA.js +0 -7
  190. package/out/chunk-O4RJY5TP.js +0 -2
  191. package/out/chunk-OD4LPN3H.js +0 -1
  192. package/out/chunk-QWDWTBM7.js +0 -1
  193. package/out/chunk-QYWKC557.js +0 -1
  194. package/out/chunk-R75XOKUP.js +0 -1
  195. package/out/chunk-SWTLADW5.js +0 -1
  196. package/out/chunk-T7OKH4VW.js +0 -1
  197. package/out/chunk-TZ5KB2Y5.js +0 -10
  198. package/out/chunk-UNTRGE26.js +0 -2
  199. package/out/chunk-UTR2LVME.js +0 -2
  200. package/out/chunk-UYAXVRVL.js +0 -1
  201. package/out/chunk-V3ME6FI5.js +0 -2
  202. package/out/chunk-V5O7M2YT.js +0 -1
  203. package/out/chunk-VIHGO77Y.js +0 -1
  204. package/out/chunk-W4J3MGLB.js +0 -1
  205. package/out/chunk-WGG7KQUH.js +0 -1
  206. package/out/chunk-XYFXXB2G.js +0 -10
  207. package/out/chunk-ZESLQCTF.js +0 -6
@@ -0,0 +1,181 @@
1
+ // The issues and pull requests the code viewer's Issues and Pull requests
2
+ // tabs show (GET /api/admin/repositories/issues): those open, or those
3
+ // closed (a pull request's merged among them), the most recently updated
4
+ // first, a page at a time, each as one tile has room for, with how many are
5
+ // open and closed. A pull request's tile names its branches, its size, its
6
+ // review and its checks, which GraphQL alone gives, so it needs a token; an
7
+ // open one's, and its popup's, whether it changes a lockfile whose new
8
+ // packages are told (pull-dependencies.ts touchesLockfile), and its reviews
9
+ // (pull-reviews.ts), each reviewer's. A nonpublic repository's are read with
10
+ // its GitHub App's installation, which reads pull requests only with their
11
+ // own permission (README).
12
+ import { GithubApiError } from './github-app.ts'
13
+ import { touchesLockfile } from './pull-dependencies.ts'
14
+ import { ACTOR, type GraphAccount, type GraphqlError, actorOf, githubGraphql } from './repository-history.ts'
15
+ import { ITEM_THREADS, type ReviewNode, TILE_THREADS, pullReviews, reviewFields, reviewsRefused } from './pull-reviews.ts'
16
+
17
+ export const ISSUES_PAGE = 50
18
+ // What a kept page or item was read as: kept ones read before differ.
19
+ export const ISSUES_READ_VERSION = 3
20
+ const MAX_LABELS = 20
21
+ const MAX_TITLE = 1000
22
+ const MAX_NAME = 256
23
+ const COLOR = /^[a-f\d]{6}$/iu
24
+ // A page's cursor as GitHub gives it, to read on from: opaque, base64 or
25
+ // base64url as it is now (`…rOrK_u7Q==`), passed back to GitHub alone, so any
26
+ // printable one is, but none with spaces or control characters.
27
+ export const ISSUES_CURSOR = /^[!-~]{1,256}$/u
28
+
29
+ export type IssuesKind = 'issues' | 'pulls'
30
+ export type IssuesState = 'open' | 'closed'
31
+
32
+ const shared = `number title createdAt updatedAt closedAt author { ${ACTOR} } comments { totalCount } labels(first: ${MAX_LABELS}) { totalCount nodes { name color } }`
33
+ const fields = {
34
+ issues: `${shared} state stateReason`,
35
+ pulls: `${shared} state isDraft mergedAt reviewDecision additions deletions headRefName baseRefName
36
+ commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`,
37
+ }
38
+ // The files a pull request changes, as many as LISTED_FILES, as an open one's
39
+ // tile and its popup tell whether it changes a lockfile by them.
40
+ const LISTED_FILES = 100
41
+ const files = `files(first: ${LISTED_FILES}) { totalCount nodes { path } }`
42
+ const connection = { issues: 'issues', pulls: 'pullRequests' }
43
+ const states = { issues: { open: '[OPEN]', closed: '[CLOSED]' }, pulls: { open: '[OPEN]', closed: '[CLOSED, MERGED]' } }
44
+
45
+ // A page of `kind`, those in `state`, after the page `after` ends, and how
46
+ // many of each state there are; open pull requests with their reviews.
47
+ const pageQuery = (kind: IssuesKind, state: IssuesState) => `query($owner: String!, $name: String!, $after: String) {
48
+ repository(owner: $owner, name: $name) {
49
+ open: ${connection[kind]}(states: ${states[kind].open}) { totalCount }
50
+ closed: ${connection[kind]}(states: ${states[kind].closed}) { totalCount }
51
+ page: ${connection[kind]}(states: ${states[kind][state]}, first: ${ISSUES_PAGE}, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
52
+ pageInfo { hasNextPage endCursor }
53
+ nodes { ${fields[kind]}${kind === 'pulls' && state === 'open' ? ` ${files} ${reviewFields(TILE_THREADS, false)}` : ''} }
54
+ }
55
+ }
56
+ }`
57
+
58
+ type Node = ReviewNode & { number?: unknown; title?: unknown; createdAt?: unknown; updatedAt?: unknown; closedAt?: unknown; mergedAt?: unknown; author?: GraphAccount
59
+ comments?: { totalCount?: unknown }; labels?: { totalCount?: unknown; nodes?: { name?: unknown; color?: unknown }[] }; state?: unknown; stateReason?: unknown
60
+ isDraft?: unknown; reviewDecision?: unknown; additions?: unknown; deletions?: unknown; headRefName?: unknown; baseRefName?: unknown
61
+ commits?: { nodes?: { commit?: { statusCheckRollup?: { state?: unknown } | null } | null }[] }
62
+ files?: { totalCount?: unknown; nodes?: ({ path?: unknown } | null)[] } | null }
63
+ type Page = { repository?: { open?: { totalCount?: unknown }; closed?: { totalCount?: unknown }
64
+ page?: { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: (Node | null)[] } } | null }
65
+
66
+ const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
67
+ const timeOf = (value: unknown) => {
68
+ const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
69
+ return Number.isSafeInteger(at) ? at : null
70
+ }
71
+ const nameOf = (value: unknown, max = MAX_NAME) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null
72
+ const REVIEWS: Record<string, string> = { APPROVED: 'approved', CHANGES_REQUESTED: 'changes', REVIEW_REQUIRED: 'required' }
73
+ const CHECKS: Record<string, string> = { SUCCESS: 'success', FAILURE: 'failure', ERROR: 'failure', PENDING: 'pending', EXPECTED: 'pending' }
74
+ const REASONS: Record<string, string> = { COMPLETED: 'completed', NOT_PLANNED: 'not-planned', DUPLICATE: 'duplicate' }
75
+
76
+ // An issue or pull request as a tile shows it; null for one GitHub names no
77
+ // number for.
78
+ function item(kind: IssuesKind, node: Node | null) {
79
+ if (!node || !Number.isSafeInteger(node.number) || (node.number as number) <= 0) return null
80
+ const author = actorOf(node.author)
81
+ const labels = (node.labels?.nodes ?? []).flatMap(label => {
82
+ const name = nameOf(label?.name)
83
+ return name ? [{ name, color: typeof label?.color === 'string' && COLOR.test(label.color) ? label.color.toLowerCase() : null }] : []
84
+ })
85
+ const common = { number: node.number as number, title: nameOf(node.title, MAX_TITLE) ?? '', author: author.login, authorId: author.id, createdAt: timeOf(node.createdAt), updatedAt: timeOf(node.updatedAt),
86
+ closedAt: timeOf(node.closedAt), comments: count(node.comments?.totalCount), labels, moreLabels: Math.max(0, count(node.labels?.totalCount) - labels.length) }
87
+ if (kind === 'issues') {
88
+ return { ...common, state: node.state === 'OPEN' ? 'open' : 'closed', reason: REASONS[String(node.stateReason)] ?? null }
89
+ }
90
+ const checks = node.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state
91
+ const listed = node.files?.nodes
92
+ const paths = Array.isArray(listed) ? listed.flatMap(file => typeof file?.path === 'string' ? [file.path] : []) : null
93
+ return { ...common, state: node.state === 'MERGED' ? 'merged' : node.state === 'OPEN' ? 'open' : 'closed', draft: node.isDraft === true, mergedAt: timeOf(node.mergedAt),
94
+ review: REVIEWS[String(node.reviewDecision)] ?? null, checks: CHECKS[String(checks)] ?? null, additions: count(node.additions), deletions: count(node.deletions),
95
+ head: nameOf(node.headRefName), base: nameOf(node.baseRefName),
96
+ ...paths ? { touchesLockfile: touchesLockfile(paths, count(node.files?.totalCount)) } : {} }
97
+ }
98
+
99
+ // A pull request's checks, which a GitHub App reads only with the Checks and
100
+ // Commit statuses permissions, refused: its tile then shows none; or its
101
+ // files, whose lockfiles' new packages it then tells none of. Any other
102
+ // field refused, the list is, as one without Pull requests: read is.
103
+ const checksRefused = (error: GraphqlError) => error?.type === 'FORBIDDEN' && Array.isArray(error.path)
104
+ && (error.path.includes('statusCheckRollup') || error.path.includes('files'))
105
+ const partRefused = (error: GraphqlError) => checksRefused(error) || reviewsRefused(error)
106
+
107
+ // One issue or pull request, as its popup shows it (GET
108
+ // /api/admin/repositories/item): as its tile does, with its description, its
109
+ // Markdown as written (never GitHub's HTML of it); an issue's pull requests closing
110
+ // it, a pull request's issues it closes, its commits and files changed, and
111
+ // who merged it. Its state, of a different type each, named apart. A
112
+ // description longer than MAX_BODY is shortened (`bodyShortened`), and the
113
+ // linked past the first LINKED counted (`moreLinked`), the rest on GitHub.
114
+ const MAX_BODY = 20_000
115
+ const LINKED = 10
116
+ const ITEM_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
117
+ repository(owner: $owner, name: $name) {
118
+ item: issueOrPullRequest(number: $number) {
119
+ __typename
120
+ ... on Issue { ${shared} issueState: state stateReason body
121
+ closedByPullRequestsReferences(first: ${LINKED}, includeClosedPrs: true) { totalCount nodes { number title pullState: state isDraft } } }
122
+ ... on PullRequest { ${shared} pullState: state isDraft mergedAt reviewDecision additions deletions headRefName baseRefName body changedFiles ${files}
123
+ commits(last: 1) { totalCount nodes { commit { statusCheckRollup { state } } } } mergedBy { ${ACTOR} }
124
+ closingIssuesReferences(first: ${LINKED}) { totalCount nodes { number title issueState: state stateReason } }
125
+ ${reviewFields(ITEM_THREADS, true)} }
126
+ }
127
+ }
128
+ }`
129
+ type Linked = { number?: unknown; title?: unknown; issueState?: unknown; pullState?: unknown; stateReason?: unknown; isDraft?: unknown } | null
130
+ type ItemNode = Node & { __typename?: unknown; issueState?: unknown; pullState?: unknown; body?: unknown; changedFiles?: unknown
131
+ commits?: Node['commits'] & { totalCount?: unknown }; mergedBy?: GraphAccount
132
+ closedByPullRequestsReferences?: Links; closingIssuesReferences?: Links }
133
+ type Links = { totalCount?: unknown; nodes?: Linked[] } | null
134
+
135
+ // The issues or pull requests an item names, each by its number, title and
136
+ // state, as `kind`'s tile names one.
137
+ const linked = (kind: IssuesKind, nodes: Linked[] | undefined) => (nodes ?? []).flatMap(node => {
138
+ const tile = item(kind, node ? { number: node.number, title: node.title, state: node.issueState ?? node.pullState, stateReason: node.stateReason, isDraft: node.isDraft } : null)
139
+ return tile ? [{ number: tile.number, title: tile.title, state: tile.state, ...'reason' in tile ? { reason: tile.reason } : { draft: tile.draft } }] : []
140
+ })
141
+
142
+ // The issue or pull request numbered `number`, its `kind` with it.
143
+ export async function repositoryItem(fullName: string, token: string, number: number, fetchImpl: typeof fetch = globalThis.fetch) {
144
+ const [owner, name] = fullName.split('/') as [string, string]
145
+ const data = await githubGraphql(ITEM_QUERY, { owner, name, number }, token, fetchImpl, partRefused) as { repository?: { item?: ItemNode | null } | null }
146
+ const node = data?.repository?.item
147
+ const kind: IssuesKind | null = node?.__typename === 'Issue' ? 'issues' : node?.__typename === 'PullRequest' ? 'pulls' : null
148
+ const tile = kind && node ? item(kind, { ...node, state: kind === 'issues' ? node.issueState : node.pullState }) : null
149
+ if (!kind || !node || !tile) throw new GithubApiError(404, 'github-not-found')
150
+ // As written, but for blank lines before it and spaces after: a first
151
+ // line's indent, an indented code block's, kept.
152
+ const written = typeof node.body === 'string' ? node.body.replace(/^(?:[ \t]*\r?\n)+/u, '').trimEnd() : ''
153
+ const links = kind === 'issues' ? node.closedByPullRequestsReferences : node.closingIssuesReferences
154
+ const named = linked(kind === 'issues' ? 'pulls' : 'issues', links?.nodes)
155
+ const described = { body: written.slice(0, MAX_BODY), bodyShortened: written.length > MAX_BODY, moreLinked: Math.max(0, count(links?.totalCount) - named.length) }
156
+ const merger = actorOf(node.mergedBy)
157
+ return kind === 'issues' ? { kind, ...tile, ...described, closedBy: named }
158
+ : { kind, ...tile, ...described, closes: named, commits: count(node.commits?.totalCount), files: count(node.changedFiles),
159
+ mergedBy: merger.login, mergedById: merger.id, reviews: pullReviews(node, tile.author, true) }
160
+ }
161
+
162
+ // A page of the repository's issues or pull requests in `state`, after the
163
+ // page `after` ended (null for the first): its `items`, how many are open and
164
+ // closed (`counts`), and, where more follow, the cursor reading them (`after`).
165
+ export async function repositoryIssues(fullName: string, token: string, kind: IssuesKind, state: IssuesState, after: string | null, fetchImpl: typeof fetch = globalThis.fetch) {
166
+ const [owner, name] = fullName.split('/') as [string, string]
167
+ const data = await githubGraphql(pageQuery(kind, state), { owner, name, after }, token, fetchImpl, partRefused) as Page
168
+ const repository = data?.repository
169
+ if (!repository) throw new GithubApiError(404, 'github-not-found')
170
+ const info = repository.page?.pageInfo
171
+ const next = info?.hasNextPage === true && typeof info.endCursor === 'string' && ISSUES_CURSOR.test(info.endCursor) ? info.endCursor : null
172
+ return {
173
+ items: (repository.page?.nodes ?? []).flatMap(node => {
174
+ const tile = item(kind, node)
175
+ if (!tile) return []
176
+ return kind === 'pulls' && state === 'open' && node ? [{ ...tile, reviews: pullReviews(node, tile.author) }] : [tile]
177
+ }),
178
+ counts: { open: count(repository.open?.totalCount), closed: count(repository.closed?.totalCount) },
179
+ more: next !== null, after: next,
180
+ }
181
+ }
@@ -0,0 +1,439 @@
1
+ // What the code viewer's Pulse tab shows of a repository
2
+ // (GET /api/admin/repositories/pulse): how its default branch moves, week by
3
+ // week over the last year: its commits each day, the lines they add and
4
+ // remove, those an agent made or co-authored, who made them and when in the
5
+ // week, counted from its commits as the Commits tab reads and keeps them
6
+ // (repository-history.ts historyFrom), so that only those not kept yet are
7
+ // read of GitHub, a few queries an answer, the client asking again for the
8
+ // rest; a year past YEAR_COMMITS is counted by GitHub's statistics instead,
9
+ // which GitHub computes when first asked and again once the default branch
10
+ // moves, answering 202 till they are (told pending, StatsPending, for the
11
+ // client to ask again). And, since
12
+ // the start of the week `weeks` weeks back, and over the same days of the
13
+ // week `weeks` weeks earlier, the pull requests merged and opened and the
14
+ // issues closed and opened, how many and the latest of each.
15
+ import { agentOperated, githubAgent } from '../common/managed/github-agents.ts'
16
+ import { GithubApiError, githubJson } from './github-app.ts'
17
+ import { shareGithubRead } from './github-requests.ts'
18
+ import { ACTOR, type GraphAccount, type HistoryCommit, type HistoryPages, accountOf, actorOf, githubGraphql, historyFrom } from './repository-history.ts'
19
+
20
+ export const PULSE_WEEKS = [1, 4, 13, 52]
21
+ // What a year counted from its commits holds, its version: kept years of
22
+ // another are counted again.
23
+ export const PULSE_YEAR_VERSION = 8
24
+ const WEEK_MS = 7 * 24 * 60 * 60_000
25
+ const DAY_MS = 24 * 60 * 60_000
26
+ const MAX_CONTRIBUTORS = 100
27
+ const DIRECT_LISTED = 500
28
+ const LISTED = 10
29
+ // Merged pull requests and closed issues are searched by when each was last
30
+ // updated, GitHub sorting by no merge or close: as many as this are read, and
31
+ // the latest merged or closed of them listed.
32
+ const SEARCHED = 50
33
+ // A year of more commits than this is counted by GitHub's statistics: read a
34
+ // page at a time, it would take a hundred queries. Of those not kept, as
35
+ // many queries are made an answer as YEAR_QUERIES, each reading on from as
36
+ // many commits as YEAR_STARTS.
37
+ export const YEAR_COMMITS = 10_000
38
+ const YEAR_QUERIES = 5
39
+ const YEAR_STARTS = 10
40
+ // Commits are read on from as far as this before the year, a commit's clock
41
+ // not always later than its parents': one dated before the year may have a
42
+ // parent dated in it.
43
+ const YEAR_SKEW_MS = 30 * DAY_MS
44
+ const MAX_TITLE = 1000
45
+ const SHA = /^[a-f\d]{40}$/u
46
+
47
+ // GitHub still computing a statistic.
48
+ export class StatsPending extends Error {
49
+ constructor() { super('github-stats-pending') }
50
+ }
51
+
52
+ // A week: its commits each day (Sunday first) and in all, the lines they
53
+ // added and removed, and, counted from its commits, each day's lines
54
+ // (`dayLines`, `[added, removed]`), the commits that release the repository
55
+ // each day (`releaseDays`, release-commits.ts) and, of the rest, those an
56
+ // agent made or co-authored (`agentDays`), and those pushed to the branch
57
+ // directly (`directDays`, agent-merges.ts directCommits), which GitHub's
58
+ // statistics don't count.
59
+ export interface PulseWeek { week: number; days: number[]; commits: number; additions: number | null; deletions: number | null; dayLines?: [number, number][]
60
+ agentDays?: number[]; releaseDays?: number[]; directDays?: number[] }
61
+ // A contributor, by their login and their account's numeric id (`id`, where
62
+ // GitHub named it), else by the name their commits carry, and their weeks,
63
+ // each `[week, commits, additions, deletions]`, those with any; counted from
64
+ // their commits, their days too, each `[day, commits, additions, deletions,
65
+ // agents, releases, direct]`, `releases` those that release the repository,
66
+ // `agents`, of the rest, those an agent took part in (withAgent), and
67
+ // `direct` those pushed to the branch directly.
68
+ export interface PulseContributor { login: string | null; id?: number | null; name: string | null; weeks: [number, number, number, number][]
69
+ days?: [number, number, number, number, number, number, number][] }
70
+ // A commit pushed to the branch directly, as Pulse lists it: its id, when it
71
+ // was committed, its subject, its author, by account, else name, and what it
72
+ // is: a release, else one an agent took part in, else one without.
73
+ export interface PulseCommit { sha: string; at: number; subject: string; login: string | null; id: number | null; name: string | null
74
+ kind: 'agent' | 'without' | 'release' }
75
+ // Commits by day of the week and hour (`punch[day][hour]`, Sunday first), in
76
+ // each commit's own time zone, and, counted from its commits, each period's
77
+ // (`punches`, by its weeks); `lines` false where GitHub counts none, null
78
+ // while it is counting them; counted from its commits, the latest pushed to
79
+ // the branch directly (`directCommits`, as many as DIRECT_LISTED).
80
+ export interface PulseStats { weeks: PulseWeek[]; contributors: PulseContributor[]; punch: number[][]; punches?: Record<number, number[][]>; lines: boolean | null
81
+ directCommits?: PulseCommit[] }
82
+ // The lines added and removed a week, and each contributor's, as GitHub's
83
+ // statistics count them, `lines` false where it counts none.
84
+ export interface PulseLines { weeks: [number, number, number][]; contributors: { login: string; weeks: [number, number, number][] }[]; lines: boolean }
85
+ // The days (`from`, `to`, both days, UTC; `to` none, on) and the authors (GitHub
86
+ // logins, any of them) a period's pull requests and issues are narrowed to.
87
+ export interface PulseNarrowing { from: number; to: number | null; authors: string[] }
88
+ // The year's commits read so far: all of them (`commits`), else how many
89
+ // (`read`), and whether past YEAR_COMMITS (`tooMany`).
90
+ export type YearCommits = { complete: true; commits: HistoryCommit[]; passed: HistoryCommit[] } | { complete: false; read: number; tooMany: boolean }
91
+
92
+ const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
93
+ const timeOf = (value: unknown) => {
94
+ const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
95
+ return Number.isSafeInteger(at) ? at : null
96
+ }
97
+ const repoApi = (fullName: string) => `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
98
+ const emptyPunch = () => Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0))
99
+
100
+ // One statistic: its rows once GitHub has computed it, 'pending' while it
101
+ // computes them (202), none for a repository with no commits (204, no body),
102
+ // 'too-large' where it counts none (lines past 10,000 commits, 422).
103
+ function statistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch) {
104
+ // Stats and lines both need contributors. Share by the original transport;
105
+ // the per-call status wrapper below would otherwise give each its own read.
106
+ return shareGithubRead(fetchImpl, JSON.stringify(['statistic', fullName, token, kind]), () => readStatistic(fullName, token, kind, fetchImpl))
107
+ }
108
+
109
+ async function readStatistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch): Promise<unknown[] | 'pending' | 'too-large'> {
110
+ let status = 0
111
+ const answered: typeof fetch = async (input, init) => {
112
+ const res = await fetchImpl(input, init)
113
+ status = res.status
114
+ return res
115
+ }
116
+ try {
117
+ const data = await githubJson(`${repoApi(fullName)}/stats/${kind}`, token, answered)
118
+ if (status === 202) return 'pending'
119
+ if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
120
+ return data
121
+ } catch (err) {
122
+ if (status === 202) return 'pending'
123
+ if (status === 204) return []
124
+ if (err instanceof GithubApiError && err.message === 'github-status-422') return 'too-large'
125
+ throw err
126
+ }
127
+ }
128
+
129
+ // `kinds` of GitHub's statistics, each asked once, together; any still
130
+ // computed, StatsPending.
131
+ async function statistics(fullName: string, token: string | null, kinds: string[], fetchImpl: typeof fetch) {
132
+ const read = await Promise.all(kinds.map(kind => statistic(fullName, token, kind, fetchImpl)))
133
+ if (read.includes('pending')) throw new StatsPending()
134
+ return read.map(rows => rows === 'too-large' ? null : rows as unknown[])
135
+ }
136
+
137
+ type ActivityRow = { week?: unknown; days?: unknown; total?: unknown }
138
+ type ContributorRow = { author?: { login?: unknown; id?: unknown } | null; weeks?: { w?: unknown; a?: unknown; d?: unknown; c?: unknown }[] }
139
+
140
+ // The default branch's commits over the last year as GitHub's statistics
141
+ // count them, for a year past YEAR_COMMITS: a week a row, the oldest first,
142
+ // its commits each day (Sunday first) and in all; its contributors (as many
143
+ // as MAX_CONTRIBUTORS, the most commits that year first) and their weeks;
144
+ // and its commits by day of the week and hour, in each commit's own time
145
+ // zone. Lines are GitHub's to count apart (repositoryLines).
146
+ export async function repositoryStats(fullName: string, token: string | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<PulseStats> {
147
+ const [activityRows = null, contributorRows = null, punchRows = null] = await statistics(fullName, token, ['commit_activity', 'contributors', 'punch_card'], fetchImpl)
148
+ const weeks = ((activityRows ?? []) as ActivityRow[]).flatMap(row => {
149
+ if (!Number.isSafeInteger(row?.week)) return []
150
+ const days = Array.from({ length: 7 }, (_, day) => count(Array.isArray(row.days) ? row.days[day] : 0))
151
+ return [{ week: (row.week as number) * 1000, days, commits: count(row.total), additions: null, deletions: null }]
152
+ }).toSorted((a, b) => a.week - b.week)
153
+ const first = weeks[0]?.week ?? Number.POSITIVE_INFINITY
154
+ const contributors = ((contributorRows ?? []) as ContributorRow[]).flatMap(row => {
155
+ const { login, id } = accountOf(row?.author)
156
+ const theirs = (row?.weeks ?? []).flatMap(item => {
157
+ const week = count(item?.w) * 1000
158
+ return week >= first && count(item?.c) > 0 ? [[week, count(item?.c), 0, 0] as [number, number, number, number]] : []
159
+ })
160
+ return login && theirs.length > 0 ? [{ login, id, name: null, weeks: theirs, commits: theirs.reduce((sum, item) => sum + item[1], 0) }] : []
161
+ }).toSorted((a, b) => b.commits - a.commits).slice(0, MAX_CONTRIBUTORS).map(({ login, id, name, weeks: theirs }) => ({ login, id, name, weeks: theirs }))
162
+ const punch = emptyPunch()
163
+ for (const row of (punchRows ?? []) as unknown[][]) {
164
+ if (Array.isArray(row) && Number.isSafeInteger(row[0]) && Number.isSafeInteger(row[1]) && punch[row[0] as number]?.[row[1] as number] !== undefined) {
165
+ punch[row[0] as number]![row[1] as number] = count(row[2])
166
+ }
167
+ }
168
+ return { weeks, contributors, punch, lines: null }
169
+ }
170
+
171
+ // The lines added and removed each week since `since`, and each
172
+ // contributor's, as GitHub's statistics count them; none, `lines` false,
173
+ // where it counts none (past 10,000 commits, where it counts each
174
+ // contributor's as none too).
175
+ export async function repositoryLines(fullName: string, token: string | null, since: number, fetchImpl: typeof fetch = globalThis.fetch): Promise<PulseLines> {
176
+ const [frequency = null, contributorRows = null] = await statistics(fullName, token, ['code_frequency', 'contributors'], fetchImpl)
177
+ if (frequency === null) return { weeks: [], contributors: [], lines: false }
178
+ const weeks = frequency.flatMap(row => Array.isArray(row) && Number.isSafeInteger(row[0]) && (row[0] as number) * 1000 >= since
179
+ ? [[(row[0] as number) * 1000, count(row[1]), count(Math.abs(Number(row[2])))] as [number, number, number]] : [])
180
+ const contributors = ((contributorRows ?? []) as ContributorRow[]).flatMap(row => {
181
+ const { login } = accountOf(row?.author)
182
+ const theirs = (row?.weeks ?? []).flatMap(item => count(item?.w) * 1000 >= since && count(item?.a) + count(item?.d) > 0
183
+ ? [[count(item?.w) * 1000, count(item?.a), count(item?.d)] as [number, number, number]] : [])
184
+ return login && theirs.length > 0 ? [{ login, weeks: theirs }] : []
185
+ })
186
+ return { weeks, contributors, lines: true }
187
+ }
188
+
189
+ // The default branch's head, null for a repository with none.
190
+ export async function defaultHead(fullName: string, token: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<string | null> {
191
+ const [owner, name] = fullName.split('/') as [string, string]
192
+ const data = await githubGraphql('query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { defaultBranchRef { target { oid } } } }',
193
+ { owner, name }, token, fetchImpl) as { repository?: { defaultBranchRef?: { target?: { oid?: unknown } | null } | null } | null } | null
194
+ if (!data?.repository) throw new GithubApiError(404, 'github-not-found')
195
+ const oid = data.repository.defaultBranchRef?.target?.oid
196
+ return typeof oid === 'string' && SHA.test(oid) ? oid : null
197
+ }
198
+
199
+ // The commits from `head` made since `since`, as the Commits tab reads and
200
+ // keeps them (`pages`, historyFrom): on from the parents of those since (or
201
+ // as much as YEAR_SKEW_MS before), not yet listed, YEAR_STARTS at a time,
202
+ // till none is left; the pages kept read
203
+ // of the store, those not of GitHub, as many queries of them as
204
+ // YEAR_QUERIES, the rest left for the next to ask (`complete` false, how
205
+ // many are `read`); more than YEAR_COMMITS, too many (`tooMany`). Read
206
+ // whole, those it was read on past besides (`passed`): undated, or dated
207
+ // before it, a clock behind, the branch's commits since beneath them.
208
+ export async function yearCommits(fullName: string, token: string, head: string, since: number, pages: HistoryPages,
209
+ fetchImpl: typeof fetch = globalThis.fetch): Promise<YearCommits> {
210
+ const found = new Map<string, HistoryCommit>()
211
+ const tried = new Set<string>()
212
+ const within = (commit: HistoryCommit) => commit.committedAt === null || commit.committedAt >= since - YEAR_SKEW_MS
213
+ const dated = (commit: HistoryCommit) => commit.committedAt !== null && commit.committedAt >= since
214
+ const read = () => [...found.values()].filter(dated).length
215
+ let queries = 0
216
+ let starts = [head]
217
+ while (starts.length > 0) {
218
+ if (queries >= YEAR_QUERIES) return { complete: false, read: read(), tooMany: false }
219
+ const batch = starts.slice(0, YEAR_STARTS)
220
+ for (const sha of batch) tried.add(sha)
221
+ const got = await historyFrom(fullName, token, batch, pages, fetchImpl)
222
+ if (got.read > 0) queries++
223
+ for (const commit of got.commits) if (!found.has(commit.sha)) found.set(commit.sha, commit)
224
+ if (read() > YEAR_COMMITS) return { complete: false, read: read(), tooMany: true }
225
+ starts = [...new Set([...found.values()].filter(within).flatMap(commit => commit.parents))].filter(sha => !found.has(sha) && !tried.has(sha))
226
+ }
227
+ return { complete: true, commits: [...found.values()].filter(dated), passed: [...found.values()].filter(commit => !dated(commit) && within(commit)) }
228
+ }
229
+
230
+ // Whether an agent made `commit` or co-authored it: its author one of
231
+ // GitHub's agents (common/managed/github-agents.ts githubAgent), or any of
232
+ // its co-authors an agent (commit-coauthors.ts coauthorsOf), as Commits
233
+ // marks them; or one an agent's pull request brought (`merges`,
234
+ // agent-merges.ts agentCommits); or one an account agents work as pushed to
235
+ // the branch directly (`direct`, github-agents.ts agentOperated).
236
+ const withAgent = (commit: HistoryCommit, merges: ReadonlySet<string>, direct: ReadonlySet<string>) => merges.has(commit.sha)
237
+ || (direct.has(commit.sha) && agentOperated(commit.authorLogin, commit.authorId ?? null))
238
+ || githubAgent(commit.authorLogin, commit.authorId ?? null, commit.authorName) !== null || (commit.coauthors ?? []).some(coauthor => coauthor.agent)
239
+
240
+ // The year from `first` (pulseSince(52, …)) of `commits`, as GitHub's
241
+ // statistics count them, merges left out: a week a row, its commits each day
242
+ // (UTC) and in all and the lines they added and removed, in all and each day,
243
+ // those that release the repository (`releases`, release-commits.ts) and,
244
+ // of the rest, those an agent made or co-authored, merged from an agent's
245
+ // pull request (`merges`) or pushed directly as an account agents work as
246
+ // (`direct`, withAgent), each day;
247
+ // its contributors,
248
+ // by account (its id, by the login it committed as first listed, the newest,
249
+ // whatever it was renamed from; a login's commits read before ids were, the
250
+ // one account's that committed as it, where only one did), else login, each
251
+ // matched as GitHub matches logins, regardless of case, else name (as many
252
+ // as MAX_CONTRIBUTORS, the most commits first),
253
+ // and their weeks and days, each day's releases and commits with agents too;
254
+ // and its commits by day of the week and hour, in each author's own time
255
+ // zone (else UTC, where GitHub told none), the year's and each period's
256
+ // (PULSE_WEEKS, its last weeks).
257
+ export function commitStats(commits: HistoryCommit[], first: number, merges: ReadonlySet<string> = new Set(), releases: ReadonlySet<string> = new Set(),
258
+ direct: ReadonlySet<string> = new Set()): PulseStats {
259
+ const weeks = Array.from({ length: 52 }, (_, at) => ({ week: first + at * WEEK_MS, days: Array.from({ length: 7 }, () => 0), commits: 0, additions: 0, deletions: 0,
260
+ dayLines: Array.from({ length: 7 }, () => [0, 0] as [number, number]), agentDays: Array.from({ length: 7 }, () => 0), releaseDays: Array.from({ length: 7 }, () => 0),
261
+ directDays: Array.from({ length: 7 }, () => 0) }))
262
+ type Counts = Map<number, number[]>
263
+ const people = new Map<string, { login: string | null; logins: Set<string>; id: number | null; name: string | null; weeks: Counts; days: Counts; commits: number }>()
264
+ const punches = Object.fromEntries(PULSE_WEEKS.map(span => [span, emptyPunch()]))
265
+ // Each commit's author, by the key their commits are counted by, and of a
266
+ // login an account took on, that account (`merged`).
267
+ const keys = new Map<string, string>()
268
+ const merged = new Map<string, { login: string | null; id: number | null; name: string | null }>()
269
+ const add = (counts: Counts, at: number, made: number[]) => counts.set(at, made.map((value, index) => (counts.get(at)?.[index] ?? 0) + value))
270
+ for (const commit of commits) {
271
+ if (commit.parents.length > 1 || commit.committedAt === null || commit.committedAt < first) continue
272
+ const at = Math.floor((commit.committedAt - first) / WEEK_MS)
273
+ const week = weeks[at]
274
+ if (!week) continue
275
+ const [additions, deletions] = [commit.additions ?? 0, commit.deletions ?? 0]
276
+ const weekday = new Date(commit.committedAt).getUTCDay()
277
+ week.days[weekday]!++
278
+ week.dayLines[weekday]![0] += additions
279
+ week.dayLines[weekday]![1] += deletions
280
+ week.commits++
281
+ week.additions += additions
282
+ week.deletions += deletions
283
+ const release = releases.has(commit.sha)
284
+ const agent = !release && withAgent(commit, merges, direct)
285
+ if (release) week.releaseDays[weekday]!++
286
+ if (direct.has(commit.sha)) week.directDays[weekday]!++
287
+ if (agent) week.agentDays[weekday]!++
288
+ const local = new Date(commit.authoredAt != null && commit.authorZone != null ? commit.authoredAt + commit.authorZone * 60_000 : commit.committedAt)
289
+ for (const span of PULSE_WEEKS) if (at >= 52 - span) punches[span]![local.getUTCDay()]![local.getUTCHours()]!++
290
+ const key = commit.authorLogin && commit.authorId != null ? `#${commit.authorId}` : commit.authorLogin ? `@${commit.authorLogin.toLowerCase()}`
291
+ : commit.authorName ? `=${commit.authorName}` : null
292
+ if (!key) continue
293
+ keys.set(commit.sha, key)
294
+ const person = people.get(key) ?? { login: commit.authorLogin, logins: new Set<string>(), id: commit.authorId ?? null, name: commit.authorName,
295
+ weeks: new Map(), days: new Map(), commits: 0 }
296
+ if (commit.authorLogin) person.logins.add(commit.authorLogin.toLowerCase())
297
+ add(person.weeks, week.week, [1, additions, deletions])
298
+ add(person.days, Math.floor(commit.committedAt / DAY_MS) * DAY_MS, [1, additions, deletions, agent ? 1 : 0, release ? 1 : 0, direct.has(commit.sha) ? 1 : 0])
299
+ person.commits++
300
+ people.set(key, person)
301
+ }
302
+ // A login one account committed as, those of its commits read before ids
303
+ // were that account's; one more did, theirs apart, no one's.
304
+ const accounts = Map.groupBy([...people.values()].filter(person => person.id !== null).flatMap(person => [...person.logins].map(login => ({ login, person }))),
305
+ ({ login }) => login)
306
+ for (const [key, person] of people) {
307
+ const [owner, ...others] = person.login !== null && person.id === null ? accounts.get(person.login.toLowerCase()) ?? [] : []
308
+ if (!owner || others.length > 0) continue
309
+ const account = owner.person
310
+ for (const [week, counts] of person.weeks) add(account.weeks, week, counts)
311
+ for (const [day, counts] of person.days) add(account.days, day, counts)
312
+ account.commits += person.commits
313
+ people.delete(key)
314
+ merged.set(key, account)
315
+ }
316
+ const rows = (counts: Counts) => [...counts].toSorted(([a], [b]) => a - b).map(([at, made]) => [at, ...made])
317
+ const contributors = [...people.values()].toSorted((a, b) => b.commits - a.commits).slice(0, MAX_CONTRIBUTORS).map(({ login, id, name, weeks: theirs, days }) => ({
318
+ login, id, name, weeks: rows(theirs) as [number, number, number, number][], days: rows(days) as [number, number, number, number, number, number, number][] }))
319
+ // Each pushed directly by the contributor its author's commits are counted
320
+ // as, whatever account it named.
321
+ const directCommits = commits.filter(commit => direct.has(commit.sha) && commit.committedAt !== null && commit.committedAt >= first)
322
+ .toSorted((a, b) => b.committedAt! - a.committedAt!).slice(0, DIRECT_LISTED).map((commit): PulseCommit => {
323
+ const { sha, committedAt, subject, authorLogin, authorId, authorName } = commit
324
+ const author = people.get(keys.get(sha) ?? '') ?? merged.get(keys.get(sha) ?? '') ?? { login: authorLogin, id: authorId ?? null, name: authorName }
325
+ const kind = releases.has(sha) ? 'release' : withAgent(commit, merges, direct) ? 'agent' : 'without'
326
+ return { sha, at: committedAt!, subject, login: author.login, id: author.id, name: author.name, kind }
327
+ })
328
+ return { weeks, contributors, punch: punches[52]!, punches, lines: true, directCommits }
329
+ }
330
+
331
+ // The year's statistics as a period of `weeks` weeks shows them: when its
332
+ // commits are made, the period's where they were counted from its commits
333
+ // (`periodPunch`), else the year's or more, as GitHub's statistics count it;
334
+ // the commits pushed to the branch directly, the period's alone.
335
+ export function periodStats(stats: PulseStats, weeks: number) {
336
+ const { punches, directCommits, ...rest } = stats
337
+ const punch = punches?.[weeks]
338
+ const since = stats.weeks.at(-weeks)?.week ?? Number.NEGATIVE_INFINITY
339
+ return { ...rest, punch: punch ?? rest.punch, periodPunch: Boolean(punch), ...directCommits ? { directCommits: directCommits.filter(commit => commit.at >= since) } : {} }
340
+ }
341
+
342
+ // GitHub's statistics of a year past YEAR_COMMITS (repositoryStats) with
343
+ // its `lines` added in, each week's and each contributor's, by login; null
344
+ // while GitHub is counting them, `lines` null.
345
+ export function withLines(stats: PulseStats, lines: PulseLines | null): PulseStats {
346
+ if (!lines) return { ...stats, lines: null }
347
+ if (!lines.lines) return { ...stats, lines: false }
348
+ const weekly = new Map(lines.weeks.map(([week, additions, deletions]) => [week, [additions, deletions]]))
349
+ const theirs = new Map(lines.contributors.map(person => [person.login, new Map(person.weeks.map(([week, additions, deletions]) => [week, [additions, deletions]]))]))
350
+ return {
351
+ ...stats,
352
+ weeks: stats.weeks.map(week => ({ ...week, additions: weekly.get(week.week)?.[0] ?? 0, deletions: weekly.get(week.week)?.[1] ?? 0 })),
353
+ contributors: stats.contributors.map(person => ({ ...person, weeks: person.weeks.map(([week, made]) => {
354
+ const added = person.login ? theirs.get(person.login)?.get(week) : undefined
355
+ return [week, made, added?.[0] ?? 0, added?.[1] ?? 0] as [number, number, number, number]
356
+ }) })),
357
+ lines: true,
358
+ }
359
+ }
360
+
361
+ // Where a period of `weeks` weeks starts, as GitHub's statistics count weeks:
362
+ // the Sunday (UTC) that many weeks back, this week the last of them.
363
+ export function pulseSince(weeks: number, now: number) {
364
+ const today = Math.floor(now / DAY_MS) * DAY_MS
365
+ const sunday = today - new Date(today).getUTCDay() * DAY_MS
366
+ return sunday - (weeks - 1) * WEEK_MS
367
+ }
368
+
369
+ const SEARCH = (name: string, fragment: string, first = 0) => `${name}: search(type: ISSUE, query: $${name}, first: ${Math.max(first, 1)}) {
370
+ issueCount${first > 0 ? ` nodes { ...${fragment} }` : ''}
371
+ }`
372
+ const ACTIVITY_QUERY = `query($merged: String!, $mergedBefore: String!, $opened: String!, $openedBefore: String!,
373
+ $closed: String!, $closedBefore: String!, $created: String!, $createdBefore: String!) {
374
+ ${SEARCH('merged', 'pull', SEARCHED)}
375
+ ${SEARCH('mergedBefore', 'pull')}
376
+ ${SEARCH('opened', 'pull', LISTED)}
377
+ ${SEARCH('openedBefore', 'pull')}
378
+ ${SEARCH('closed', 'issue', SEARCHED)}
379
+ ${SEARCH('closedBefore', 'issue')}
380
+ ${SEARCH('created', 'issue', LISTED)}
381
+ ${SEARCH('createdBefore', 'issue')}
382
+ }
383
+ fragment pull on PullRequest { number title pullState: state isDraft createdAt mergedAt author { ${ACTOR} } }
384
+ fragment issue on Issue { number title issueState: state stateReason createdAt closedAt author { ${ACTOR} } }`
385
+
386
+ type SearchNode = { number?: unknown; title?: unknown; pullState?: unknown; issueState?: unknown; isDraft?: unknown; stateReason?: unknown
387
+ createdAt?: unknown; mergedAt?: unknown; closedAt?: unknown; author?: GraphAccount } | null
388
+ type Search = { issueCount?: unknown; nodes?: SearchNode[] } | null | undefined
389
+ const REASONS: Record<string, string> = { COMPLETED: 'completed', NOT_PLANNED: 'not-planned', DUPLICATE: 'duplicate' }
390
+
391
+ // An issue or pull request a search lists, as the popup's preview of it
392
+ // takes one, `at` when it was merged, opened or closed.
393
+ function listed(node: SearchNode, at: unknown) {
394
+ if (!node || !Number.isSafeInteger(node.number) || (node.number as number) <= 0) return null
395
+ const title = typeof node.title === 'string' ? node.title.trim().slice(0, MAX_TITLE) : ''
396
+ const author = actorOf(node.author)
397
+ const common = { number: node.number as number, title, author: author.login, authorId: author.id, at: timeOf(at), createdAt: timeOf(node.createdAt) }
398
+ return node.pullState === undefined
399
+ ? { ...common, kind: 'issues', state: node.issueState === 'OPEN' ? 'open' : 'closed', reason: REASONS[String(node.stateReason)] ?? null }
400
+ : { ...common, kind: 'pulls', state: node.pullState === 'MERGED' ? 'merged' : node.pullState === 'OPEN' ? 'open' : 'closed', draft: node.isDraft === true }
401
+ }
402
+
403
+ // The pull requests merged and opened and the issues closed and opened since
404
+ // the start of the week `weeks` weeks back (`since`), and over the same days
405
+ // of the week `weeks` weeks earlier (`previous`, its count: a week's Sunday to
406
+ // Thursday beside the week before's, not the days just before, whose days of
407
+ // the week differ), the latest of each listed, as many as LISTED: those
408
+ // opened as GitHub sorts them, those merged or closed the latest of the
409
+ // SEARCHED most recently updated, which are the latest of all where the
410
+ // period holds no more, or the last listed was merged or closed after the
411
+ // SEARCHED-th was last updated. A search counts by the day (UTC). Narrowed
412
+ // (`narrowing`), those of its days from `from` to `to` (both days, UTC;
413
+ // none, on), and those its `authors` (GitHub logins) opened, any of them.
414
+ export async function repositoryActivity(fullName: string, token: string, weeks: number, now: number = Date.now(), fetchImpl: typeof fetch = globalThis.fetch,
415
+ narrowing: PulseNarrowing | null = null) {
416
+ const since = pulseSince(weeks, now)
417
+ const back = weeks * WEEK_MS
418
+ const day = (at: number) => new Date(at).toISOString().slice(0, 10)
419
+ const current = narrowing?.to == null ? `>=${day(narrowing?.from ?? since)}` : `${day(narrowing.from)}..${day(narrowing.to)}`
420
+ const previous = `${day(since - back)}..${day(now - back)}`
421
+ const repo = `repo:${fullName}`
422
+ // An app's login (`name[bot]`) is searched for as GitHub names its app.
423
+ const by = (narrowing?.authors ?? []).map(login => ` author:${login.endsWith('[bot]') ? `app/${login.slice(0, -5)}` : login}`).join('')
424
+ const asked = (what: string, range: string, sort: string) => `${repo} ${what}${range}${by} sort:${sort}`
425
+ const kinds = {
426
+ merged: ['is:pr is:merged merged:', 'updated-desc'], opened: ['is:pr created:', 'created-desc'],
427
+ closed: ['is:issue is:closed closed:', 'updated-desc'], created: ['is:issue created:', 'created-desc'],
428
+ } as const
429
+ const variables = Object.fromEntries(Object.entries(kinds).flatMap(([name, [what, sort]]) => [[name, asked(what, current, sort)],
430
+ [`${name}Before`, asked(what, previous, sort)]]))
431
+ const data = await githubGraphql(ACTIVITY_QUERY, variables, token, fetchImpl) as Record<string, Search> | null
432
+ const at = { merged: 'mergedAt', opened: 'createdAt', closed: 'closedAt', created: 'createdAt' } as const
433
+ const told = Object.fromEntries((Object.keys(kinds) as (keyof typeof kinds)[]).map(name => [name, {
434
+ count: count(data?.[name]?.issueCount),
435
+ previous: count(data?.[`${name}Before`]?.issueCount),
436
+ items: (data?.[name]?.nodes ?? []).flatMap(node => listed(node, node?.[at[name]]) ?? []).toSorted((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, LISTED),
437
+ }]))
438
+ return { since, previousSince: since - back, ...told }
439
+ }
@@ -33,6 +33,19 @@ const AUDIT_FAILED_KEPT_MS = 10 * 60_000
33
33
  // RISK_KEPT_MS, why they couldn't be had within AUDIT_FAILED_KEPT_MS.
34
34
  const isFresh = ({ at, audit }: { at: number; audit: Audit }) => Date.now() - at < (Array.isArray(audit) ? RISK_KEPT_MS : AUDIT_FAILED_KEPT_MS)
35
35
 
36
+ // The audits of lockfiles, each of its `packages` by its `key` (auditLockfile),
37
+ // `inTurn` with others, asked at once: each null where it has none to ask
38
+ // of, or once `signal` aborts. Each is the caller's to tell once it may.
39
+ export function auditLockfiles(source: AuditSource, memory: Lru<{ at: number; audit: Audit }>, signal: AbortSignal,
40
+ lockfiles: { key: string; packages: readonly LockedPackage[] | null }[], inTurn: <T>(work: () => Promise<T>) => Promise<T>): Promise<Audit | null>[] {
41
+ return lockfiles.map(({ key, packages }) => {
42
+ const audit = packages ? inTurn(() => (signal.aborted ? Promise.resolve(null) : auditLockfile(source, memory, signal, key, packages))) : Promise.resolve(null)
43
+ // One no longer waited on, as the scan stopped, fails alone.
44
+ audit.catch(() => {})
45
+ return audit
46
+ })
47
+ }
48
+
36
49
  // The audit of a lockfile's `packages`, by its `key` as kept (`memory`, then
37
50
  // the source's), else asked and kept; null where it locks none to ask of, or
38
51
  // once `signal` aborts.