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

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 (183) hide show
  1. package/common/bundle-sources.js +20 -1
  2. package/common/default-scan-models.ts +1 -1
  3. package/common/dependency-reviews.d.ts +0 -5
  4. package/common/dependency-reviews.js +0 -17
  5. package/common/managed/agent-tasks.ts +292 -0
  6. package/common/managed/github-agents.ts +92 -0
  7. package/common/managed/github-logins.ts +49 -0
  8. package/common/managed/roles.ts +7 -0
  9. package/common/managed/routes.js +88 -7
  10. package/common/managed/team-app.d.ts +5 -2
  11. package/common/managed/team-app.js +2 -2
  12. package/common/server-info.ts +3 -0
  13. package/common/workspace-app.js +6 -3
  14. package/out/brotli-fallback.js +3 -3
  15. package/out/chunk-2XUEZHIP.js +18 -18
  16. package/out/chunk-2ZDZWLFY.js +1 -0
  17. package/out/chunk-3PS4KWD2.js +10 -0
  18. package/out/chunk-5DHL5NG3.js +2 -0
  19. package/out/chunk-6HNXNIQY.js +52 -0
  20. package/out/chunk-7C7DWL7I.js +1 -0
  21. package/out/chunk-AFLZH7WG.js +1 -0
  22. package/out/chunk-BC2UCLFJ.js +1 -0
  23. package/out/chunk-C4Z6ZBXV.js +6 -6
  24. package/out/chunk-GDPCLSOS.js +2 -0
  25. package/out/chunk-GO7FT4OD.js +1 -0
  26. package/out/chunk-HMAJDVXN.js +1 -0
  27. package/out/chunk-I2MDGSQX.js +3 -0
  28. package/out/chunk-IYPY47MC.js +1 -0
  29. package/out/chunk-IZ7POWI3.js +18 -0
  30. package/out/chunk-JCIIBLKR.js +1 -1
  31. package/out/chunk-NPW52BM2.js +17 -0
  32. package/out/chunk-NRPC74AM.js +1 -1
  33. package/out/chunk-NV7ES4EW.js +1 -0
  34. package/out/chunk-OKRLLE5I.js +17 -0
  35. package/out/chunk-OOJM4CTU.js +1 -1
  36. package/out/chunk-PNHBZGDD.js +1 -1
  37. package/out/chunk-PTGDYLNS.js +36 -0
  38. package/out/chunk-PXK7EU4V.js +1 -0
  39. package/out/chunk-Q6MVYGYV.js +3 -0
  40. package/out/chunk-QB4CBOX2.js +1 -0
  41. package/out/chunk-QRD6GIG5.js +229 -0
  42. package/out/chunk-R3NZFXVR.js +1 -0
  43. package/out/chunk-R5O6FAW4.js +1 -1
  44. package/out/chunk-SXV2T5C4.js +80 -0
  45. package/out/chunk-TUII34K2.js +1 -1
  46. package/out/chunk-VFWMQ3LG.js +2 -0
  47. package/out/chunk-VH4XMZIW.js +2 -0
  48. package/out/chunk-WFYLMF6Z.js +10 -0
  49. package/out/chunk-WI7Z5A2T.js +6 -0
  50. package/out/chunk-WQ74ZBZF.js +7 -0
  51. package/out/chunk-XVBEOGOS.js +3 -0
  52. package/out/client-managed-import.js +3 -3
  53. package/out/client-managed.js +31 -35
  54. package/out/client-sync.js +11 -11
  55. package/out/compare.js +35 -0
  56. package/out/dialogs.js +51 -0
  57. package/out/github-issue-authorized.html +1 -1
  58. package/out/github-issue-authorized.js +1 -1
  59. package/out/graph.js +17 -17
  60. package/out/index.html +30 -34
  61. package/out/prism.js +14 -14
  62. package/out/scan.js +2 -0
  63. package/out/terminal.js +294 -293
  64. package/out/view.css +1 -1
  65. package/out/view.js +16 -107
  66. package/package.json +51 -6
  67. package/risk/actions/findings.js +156 -0
  68. package/risk/actions/resolve.js +219 -0
  69. package/risk/actions/scripts/commands.js +182 -0
  70. package/risk/actions/scripts/grade.js +113 -0
  71. package/risk/actions/scripts/packages.js +123 -0
  72. package/risk/actions/scripts/shell.js +165 -0
  73. package/risk/actions/uses.js +76 -0
  74. package/risk/actions/workflow.js +110 -0
  75. package/risk/files/hosts.js +6 -6
  76. package/risk/files/readability.js +23 -17
  77. package/risk/files/runs-code.js +18 -6
  78. package/risk/files/script.js +7 -3
  79. package/risk/files/stamps.js +8 -7
  80. package/risk/index.d.ts +68 -1
  81. package/risk/index.js +6 -2
  82. package/risk/npm/assess.js +1 -1
  83. package/risk/npm/compare.js +34 -10
  84. package/risk/npm/files.js +413 -2
  85. package/risk/npm/tarball.js +56 -73
  86. package/server-managed/agent-merges.ts +198 -0
  87. package/server-managed/app-agents.ts +30 -0
  88. package/server-managed/avatar-store.ts +1 -0
  89. package/server-managed/commit-coauthors.ts +83 -0
  90. package/server-managed/commit-lockfiles.ts +62 -0
  91. package/server-managed/db-methods.ts +29 -10
  92. package/server-managed/db-neon.ts +30 -2
  93. package/server-managed/db-schema.ts +9 -2
  94. package/server-managed/db.ts +7 -0
  95. package/server-managed/dependabot-access.ts +60 -0
  96. package/server-managed/dependabot-alerts.ts +185 -0
  97. package/server-managed/dependency-review-comments.ts +97 -0
  98. package/server-managed/dependency-reviews.ts +65 -15
  99. package/server-managed/github-accounts.ts +33 -0
  100. package/server-managed/github-actions.ts +71 -0
  101. package/server-managed/github-app.ts +23 -9
  102. package/server-managed/github-errors.ts +2 -0
  103. package/server-managed/github-link-oauth.ts +69 -0
  104. package/server-managed/github-links.ts +204 -0
  105. package/server-managed/github-oauth.ts +5 -2
  106. package/server-managed/github-users.ts +30 -0
  107. package/server-managed/http.ts +1122 -52
  108. package/server-managed/index.ts +6 -2
  109. package/server-managed/locked-packages.ts +37 -16
  110. package/server-managed/lockfile-scope.ts +49 -0
  111. package/server-managed/npm-delta-worker.js +23 -0
  112. package/server-managed/npm-delta.ts +173 -0
  113. package/server-managed/npm-insights.ts +20 -11
  114. package/server-managed/npm-kept.ts +51 -4
  115. package/server-managed/npm-loads.ts +20 -13
  116. package/server-managed/npm-packages.ts +24 -64
  117. package/server-managed/pull-comment-reviews.ts +161 -0
  118. package/server-managed/pull-dependencies.ts +280 -0
  119. package/server-managed/pull-dependency-reviews.ts +318 -0
  120. package/server-managed/pull-reviews.ts +205 -0
  121. package/server-managed/release-commits.ts +84 -0
  122. package/server-managed/repository-actions.ts +209 -0
  123. package/server-managed/repository-agents.ts +220 -0
  124. package/server-managed/repository-browser.ts +54 -13
  125. package/server-managed/repository-changes.ts +284 -0
  126. package/server-managed/repository-code.ts +52 -0
  127. package/server-managed/repository-dependency-reviews.ts +135 -0
  128. package/server-managed/repository-history.ts +445 -0
  129. package/server-managed/repository-issues.ts +181 -0
  130. package/server-managed/repository-pulse.ts +432 -0
  131. package/server-managed/repository-risk-read.ts +165 -0
  132. package/server-managed/repository-risk.ts +113 -105
  133. package/server-managed/reviewed-dependencies.ts +149 -0
  134. package/server-managed/session.ts +9 -5
  135. package/server-managed/sql.ts +1 -1
  136. package/server-managed/storage-stores.ts +5 -0
  137. package/server-managed/team-app.ts +19 -3
  138. package/server-managed/workspace-shares.ts +4 -1
  139. package/server.ts +11 -0
  140. package/out/chunk-2ABLKCOY.js +0 -46
  141. package/out/chunk-2KBF5TZ7.js +0 -2
  142. package/out/chunk-35VE6CDH.js +0 -2
  143. package/out/chunk-3BZ7JERB.js +0 -1
  144. package/out/chunk-4XCDFGDP.js +0 -10
  145. package/out/chunk-5LSXOLUS.js +0 -1
  146. package/out/chunk-5RCM2J63.js +0 -1
  147. package/out/chunk-7B6HB4D7.js +0 -1
  148. package/out/chunk-AJ4GTATZ.js +0 -3
  149. package/out/chunk-CH4UN6WK.js +0 -225
  150. package/out/chunk-CMQA4DM6.js +0 -1
  151. package/out/chunk-CVVJAXVY.js +0 -2
  152. package/out/chunk-DIJJNTRH.js +0 -10
  153. package/out/chunk-EDKLBQQN.js +0 -17
  154. package/out/chunk-F47DXCEV.js +0 -33
  155. package/out/chunk-FK3DZEKL.js +0 -1
  156. package/out/chunk-G6AJWENO.js +0 -1
  157. package/out/chunk-GJ4IUCNS.js +0 -1
  158. package/out/chunk-GNXOKIGP.js +0 -80
  159. package/out/chunk-GZ4MS3TE.js +0 -13
  160. package/out/chunk-GZWM7UQM.js +0 -2
  161. package/out/chunk-IU3CCINR.js +0 -3
  162. package/out/chunk-K3X5EJBQ.js +0 -2
  163. package/out/chunk-KEX2ATEW.js +0 -2
  164. package/out/chunk-KYCNJVB7.js +0 -1
  165. package/out/chunk-LPCTJYNA.js +0 -7
  166. package/out/chunk-O4RJY5TP.js +0 -2
  167. package/out/chunk-OD4LPN3H.js +0 -1
  168. package/out/chunk-QWDWTBM7.js +0 -1
  169. package/out/chunk-QYWKC557.js +0 -1
  170. package/out/chunk-R75XOKUP.js +0 -1
  171. package/out/chunk-SWTLADW5.js +0 -1
  172. package/out/chunk-T7OKH4VW.js +0 -1
  173. package/out/chunk-TZ5KB2Y5.js +0 -10
  174. package/out/chunk-UNTRGE26.js +0 -2
  175. package/out/chunk-UTR2LVME.js +0 -2
  176. package/out/chunk-UYAXVRVL.js +0 -1
  177. package/out/chunk-V3ME6FI5.js +0 -2
  178. package/out/chunk-V5O7M2YT.js +0 -1
  179. package/out/chunk-VIHGO77Y.js +0 -1
  180. package/out/chunk-W4J3MGLB.js +0 -1
  181. package/out/chunk-WGG7KQUH.js +0 -1
  182. package/out/chunk-XYFXXB2G.js +0 -10
  183. package/out/chunk-ZESLQCTF.js +0 -6
@@ -0,0 +1,432 @@
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 { ACTOR, type GraphAccount, type HistoryCommit, type HistoryPages, accountOf, actorOf, githubGraphql, historyFrom } from './repository-history.ts'
18
+
19
+ export const PULSE_WEEKS = [1, 4, 13, 52]
20
+ // What a year counted from its commits holds, its version: kept years of
21
+ // another are counted again.
22
+ export const PULSE_YEAR_VERSION = 8
23
+ const WEEK_MS = 7 * 24 * 60 * 60_000
24
+ const DAY_MS = 24 * 60 * 60_000
25
+ const MAX_CONTRIBUTORS = 100
26
+ const DIRECT_LISTED = 500
27
+ const LISTED = 10
28
+ // Merged pull requests and closed issues are searched by when each was last
29
+ // updated, GitHub sorting by no merge or close: as many as this are read, and
30
+ // the latest merged or closed of them listed.
31
+ const SEARCHED = 50
32
+ // A year of more commits than this is counted by GitHub's statistics: read a
33
+ // page at a time, it would take a hundred queries. Of those not kept, as
34
+ // many queries are made an answer as YEAR_QUERIES, each reading on from as
35
+ // many commits as YEAR_STARTS.
36
+ export const YEAR_COMMITS = 10_000
37
+ const YEAR_QUERIES = 5
38
+ const YEAR_STARTS = 10
39
+ // Commits are read on from as far as this before the year, a commit's clock
40
+ // not always later than its parents': one dated before the year may have a
41
+ // parent dated in it.
42
+ const YEAR_SKEW_MS = 30 * DAY_MS
43
+ const MAX_TITLE = 1000
44
+ const SHA = /^[a-f\d]{40}$/u
45
+
46
+ // GitHub still computing a statistic.
47
+ export class StatsPending extends Error {
48
+ constructor() { super('github-stats-pending') }
49
+ }
50
+
51
+ // A week: its commits each day (Sunday first) and in all, the lines they
52
+ // added and removed, and, counted from its commits, each day's lines
53
+ // (`dayLines`, `[added, removed]`), the commits that release the repository
54
+ // each day (`releaseDays`, release-commits.ts) and, of the rest, those an
55
+ // agent made or co-authored (`agentDays`), and those pushed to the branch
56
+ // directly (`directDays`, agent-merges.ts directCommits), which GitHub's
57
+ // statistics don't count.
58
+ export interface PulseWeek { week: number; days: number[]; commits: number; additions: number | null; deletions: number | null; dayLines?: [number, number][]
59
+ agentDays?: number[]; releaseDays?: number[]; directDays?: number[] }
60
+ // A contributor, by their login and their account's numeric id (`id`, where
61
+ // GitHub named it), else by the name their commits carry, and their weeks,
62
+ // each `[week, commits, additions, deletions]`, those with any; counted from
63
+ // their commits, their days too, each `[day, commits, additions, deletions,
64
+ // agents, releases, direct]`, `releases` those that release the repository,
65
+ // `agents`, of the rest, those an agent took part in (withAgent), and
66
+ // `direct` those pushed to the branch directly.
67
+ export interface PulseContributor { login: string | null; id?: number | null; name: string | null; weeks: [number, number, number, number][]
68
+ days?: [number, number, number, number, number, number, number][] }
69
+ // A commit pushed to the branch directly, as Pulse lists it: its id, when it
70
+ // was committed, its subject, its author, by account, else name, and what it
71
+ // is: a release, else one an agent took part in, else one without.
72
+ export interface PulseCommit { sha: string; at: number; subject: string; login: string | null; id: number | null; name: string | null
73
+ kind: 'agent' | 'without' | 'release' }
74
+ // Commits by day of the week and hour (`punch[day][hour]`, Sunday first), in
75
+ // each commit's own time zone, and, counted from its commits, each period's
76
+ // (`punches`, by its weeks); `lines` false where GitHub counts none, null
77
+ // while it is counting them; counted from its commits, the latest pushed to
78
+ // the branch directly (`directCommits`, as many as DIRECT_LISTED).
79
+ export interface PulseStats { weeks: PulseWeek[]; contributors: PulseContributor[]; punch: number[][]; punches?: Record<number, number[][]>; lines: boolean | null
80
+ directCommits?: PulseCommit[] }
81
+ // The lines added and removed a week, and each contributor's, as GitHub's
82
+ // statistics count them, `lines` false where it counts none.
83
+ export interface PulseLines { weeks: [number, number, number][]; contributors: { login: string; weeks: [number, number, number][] }[]; lines: boolean }
84
+ // The days (`from`, `to`, both days, UTC; `to` none, on) and the authors (GitHub
85
+ // logins, any of them) a period's pull requests and issues are narrowed to.
86
+ export interface PulseNarrowing { from: number; to: number | null; authors: string[] }
87
+ // The year's commits read so far: all of them (`commits`), else how many
88
+ // (`read`), and whether past YEAR_COMMITS (`tooMany`).
89
+ export type YearCommits = { complete: true; commits: HistoryCommit[]; passed: HistoryCommit[] } | { complete: false; read: number; tooMany: boolean }
90
+
91
+ const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
92
+ const timeOf = (value: unknown) => {
93
+ const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
94
+ return Number.isSafeInteger(at) ? at : null
95
+ }
96
+ const repoApi = (fullName: string) => `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
97
+ const emptyPunch = () => Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0))
98
+
99
+ // One statistic: its rows once GitHub has computed it, 'pending' while it
100
+ // computes them (202), none for a repository with no commits (204, no body),
101
+ // 'too-large' where it counts none (lines past 10,000 commits, 422).
102
+ async function statistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch): Promise<unknown[] | 'pending' | 'too-large'> {
103
+ let status = 0
104
+ const answered: typeof fetch = async (input, init) => {
105
+ const res = await fetchImpl(input, init)
106
+ status = res.status
107
+ return res
108
+ }
109
+ try {
110
+ const data = await githubJson(`${repoApi(fullName)}/stats/${kind}`, token, answered)
111
+ if (status === 202) return 'pending'
112
+ if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
113
+ return data
114
+ } catch (err) {
115
+ if (status === 202) return 'pending'
116
+ if (status === 204) return []
117
+ if (err instanceof GithubApiError && err.message === 'github-status-422') return 'too-large'
118
+ throw err
119
+ }
120
+ }
121
+
122
+ // `kinds` of GitHub's statistics, each asked once, together; any still
123
+ // computed, StatsPending.
124
+ async function statistics(fullName: string, token: string | null, kinds: string[], fetchImpl: typeof fetch) {
125
+ const read = await Promise.all(kinds.map(kind => statistic(fullName, token, kind, fetchImpl)))
126
+ if (read.includes('pending')) throw new StatsPending()
127
+ return read.map(rows => rows === 'too-large' ? null : rows as unknown[])
128
+ }
129
+
130
+ type ActivityRow = { week?: unknown; days?: unknown; total?: unknown }
131
+ type ContributorRow = { author?: { login?: unknown; id?: unknown } | null; weeks?: { w?: unknown; a?: unknown; d?: unknown; c?: unknown }[] }
132
+
133
+ // The default branch's commits over the last year as GitHub's statistics
134
+ // count them, for a year past YEAR_COMMITS: a week a row, the oldest first,
135
+ // its commits each day (Sunday first) and in all; its contributors (as many
136
+ // as MAX_CONTRIBUTORS, the most commits that year first) and their weeks;
137
+ // and its commits by day of the week and hour, in each commit's own time
138
+ // zone. Lines are GitHub's to count apart (repositoryLines).
139
+ export async function repositoryStats(fullName: string, token: string | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<PulseStats> {
140
+ const [activityRows = null, contributorRows = null, punchRows = null] = await statistics(fullName, token, ['commit_activity', 'contributors', 'punch_card'], fetchImpl)
141
+ const weeks = ((activityRows ?? []) as ActivityRow[]).flatMap(row => {
142
+ if (!Number.isSafeInteger(row?.week)) return []
143
+ const days = Array.from({ length: 7 }, (_, day) => count(Array.isArray(row.days) ? row.days[day] : 0))
144
+ return [{ week: (row.week as number) * 1000, days, commits: count(row.total), additions: null, deletions: null }]
145
+ }).toSorted((a, b) => a.week - b.week)
146
+ const first = weeks[0]?.week ?? Number.POSITIVE_INFINITY
147
+ const contributors = ((contributorRows ?? []) as ContributorRow[]).flatMap(row => {
148
+ const { login, id } = accountOf(row?.author)
149
+ const theirs = (row?.weeks ?? []).flatMap(item => {
150
+ const week = count(item?.w) * 1000
151
+ return week >= first && count(item?.c) > 0 ? [[week, count(item?.c), 0, 0] as [number, number, number, number]] : []
152
+ })
153
+ return login && theirs.length > 0 ? [{ login, id, name: null, weeks: theirs, commits: theirs.reduce((sum, item) => sum + item[1], 0) }] : []
154
+ }).toSorted((a, b) => b.commits - a.commits).slice(0, MAX_CONTRIBUTORS).map(({ login, id, name, weeks: theirs }) => ({ login, id, name, weeks: theirs }))
155
+ const punch = emptyPunch()
156
+ for (const row of (punchRows ?? []) as unknown[][]) {
157
+ if (Array.isArray(row) && Number.isSafeInteger(row[0]) && Number.isSafeInteger(row[1]) && punch[row[0] as number]?.[row[1] as number] !== undefined) {
158
+ punch[row[0] as number]![row[1] as number] = count(row[2])
159
+ }
160
+ }
161
+ return { weeks, contributors, punch, lines: null }
162
+ }
163
+
164
+ // The lines added and removed each week since `since`, and each
165
+ // contributor's, as GitHub's statistics count them; none, `lines` false,
166
+ // where it counts none (past 10,000 commits, where it counts each
167
+ // contributor's as none too).
168
+ export async function repositoryLines(fullName: string, token: string | null, since: number, fetchImpl: typeof fetch = globalThis.fetch): Promise<PulseLines> {
169
+ const [frequency = null, contributorRows = null] = await statistics(fullName, token, ['code_frequency', 'contributors'], fetchImpl)
170
+ if (frequency === null) return { weeks: [], contributors: [], lines: false }
171
+ const weeks = frequency.flatMap(row => Array.isArray(row) && Number.isSafeInteger(row[0]) && (row[0] as number) * 1000 >= since
172
+ ? [[(row[0] as number) * 1000, count(row[1]), count(Math.abs(Number(row[2])))] as [number, number, number]] : [])
173
+ const contributors = ((contributorRows ?? []) as ContributorRow[]).flatMap(row => {
174
+ const { login } = accountOf(row?.author)
175
+ const theirs = (row?.weeks ?? []).flatMap(item => count(item?.w) * 1000 >= since && count(item?.a) + count(item?.d) > 0
176
+ ? [[count(item?.w) * 1000, count(item?.a), count(item?.d)] as [number, number, number]] : [])
177
+ return login && theirs.length > 0 ? [{ login, weeks: theirs }] : []
178
+ })
179
+ return { weeks, contributors, lines: true }
180
+ }
181
+
182
+ // The default branch's head, null for a repository with none.
183
+ export async function defaultHead(fullName: string, token: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<string | null> {
184
+ const [owner, name] = fullName.split('/') as [string, string]
185
+ const data = await githubGraphql('query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { defaultBranchRef { target { oid } } } }',
186
+ { owner, name }, token, fetchImpl) as { repository?: { defaultBranchRef?: { target?: { oid?: unknown } | null } | null } | null } | null
187
+ if (!data?.repository) throw new GithubApiError(404, 'github-not-found')
188
+ const oid = data.repository.defaultBranchRef?.target?.oid
189
+ return typeof oid === 'string' && SHA.test(oid) ? oid : null
190
+ }
191
+
192
+ // The commits from `head` made since `since`, as the Commits tab reads and
193
+ // keeps them (`pages`, historyFrom): on from the parents of those since (or
194
+ // as much as YEAR_SKEW_MS before), not yet listed, YEAR_STARTS at a time,
195
+ // till none is left; the pages kept read
196
+ // of the store, those not of GitHub, as many queries of them as
197
+ // YEAR_QUERIES, the rest left for the next to ask (`complete` false, how
198
+ // many are `read`); more than YEAR_COMMITS, too many (`tooMany`). Read
199
+ // whole, those it was read on past besides (`passed`): undated, or dated
200
+ // before it, a clock behind, the branch's commits since beneath them.
201
+ export async function yearCommits(fullName: string, token: string, head: string, since: number, pages: HistoryPages,
202
+ fetchImpl: typeof fetch = globalThis.fetch): Promise<YearCommits> {
203
+ const found = new Map<string, HistoryCommit>()
204
+ const tried = new Set<string>()
205
+ const within = (commit: HistoryCommit) => commit.committedAt === null || commit.committedAt >= since - YEAR_SKEW_MS
206
+ const dated = (commit: HistoryCommit) => commit.committedAt !== null && commit.committedAt >= since
207
+ const read = () => [...found.values()].filter(dated).length
208
+ let queries = 0
209
+ let starts = [head]
210
+ while (starts.length > 0) {
211
+ if (queries >= YEAR_QUERIES) return { complete: false, read: read(), tooMany: false }
212
+ const batch = starts.slice(0, YEAR_STARTS)
213
+ for (const sha of batch) tried.add(sha)
214
+ const got = await historyFrom(fullName, token, batch, pages, fetchImpl)
215
+ if (got.read > 0) queries++
216
+ for (const commit of got.commits) if (!found.has(commit.sha)) found.set(commit.sha, commit)
217
+ if (read() > YEAR_COMMITS) return { complete: false, read: read(), tooMany: true }
218
+ starts = [...new Set([...found.values()].filter(within).flatMap(commit => commit.parents))].filter(sha => !found.has(sha) && !tried.has(sha))
219
+ }
220
+ return { complete: true, commits: [...found.values()].filter(dated), passed: [...found.values()].filter(commit => !dated(commit) && within(commit)) }
221
+ }
222
+
223
+ // Whether an agent made `commit` or co-authored it: its author one of
224
+ // GitHub's agents (common/managed/github-agents.ts githubAgent), or any of
225
+ // its co-authors an agent (commit-coauthors.ts coauthorsOf), as Commits
226
+ // marks them; or one an agent's pull request brought (`merges`,
227
+ // agent-merges.ts agentCommits); or one an account agents work as pushed to
228
+ // the branch directly (`direct`, github-agents.ts agentOperated).
229
+ const withAgent = (commit: HistoryCommit, merges: ReadonlySet<string>, direct: ReadonlySet<string>) => merges.has(commit.sha)
230
+ || (direct.has(commit.sha) && agentOperated(commit.authorLogin, commit.authorId ?? null))
231
+ || githubAgent(commit.authorLogin, commit.authorId ?? null, commit.authorName) !== null || (commit.coauthors ?? []).some(coauthor => coauthor.agent)
232
+
233
+ // The year from `first` (pulseSince(52, …)) of `commits`, as GitHub's
234
+ // statistics count them, merges left out: a week a row, its commits each day
235
+ // (UTC) and in all and the lines they added and removed, in all and each day,
236
+ // those that release the repository (`releases`, release-commits.ts) and,
237
+ // of the rest, those an agent made or co-authored, merged from an agent's
238
+ // pull request (`merges`) or pushed directly as an account agents work as
239
+ // (`direct`, withAgent), each day;
240
+ // its contributors,
241
+ // by account (its id, by the login it committed as first listed, the newest,
242
+ // whatever it was renamed from; a login's commits read before ids were, the
243
+ // one account's that committed as it, where only one did), else login, each
244
+ // matched as GitHub matches logins, regardless of case, else name (as many
245
+ // as MAX_CONTRIBUTORS, the most commits first),
246
+ // and their weeks and days, each day's releases and commits with agents too;
247
+ // and its commits by day of the week and hour, in each author's own time
248
+ // zone (else UTC, where GitHub told none), the year's and each period's
249
+ // (PULSE_WEEKS, its last weeks).
250
+ export function commitStats(commits: HistoryCommit[], first: number, merges: ReadonlySet<string> = new Set(), releases: ReadonlySet<string> = new Set(),
251
+ direct: ReadonlySet<string> = new Set()): PulseStats {
252
+ const weeks = Array.from({ length: 52 }, (_, at) => ({ week: first + at * WEEK_MS, days: Array.from({ length: 7 }, () => 0), commits: 0, additions: 0, deletions: 0,
253
+ dayLines: Array.from({ length: 7 }, () => [0, 0] as [number, number]), agentDays: Array.from({ length: 7 }, () => 0), releaseDays: Array.from({ length: 7 }, () => 0),
254
+ directDays: Array.from({ length: 7 }, () => 0) }))
255
+ type Counts = Map<number, number[]>
256
+ const people = new Map<string, { login: string | null; logins: Set<string>; id: number | null; name: string | null; weeks: Counts; days: Counts; commits: number }>()
257
+ const punches = Object.fromEntries(PULSE_WEEKS.map(span => [span, emptyPunch()]))
258
+ // Each commit's author, by the key their commits are counted by, and of a
259
+ // login an account took on, that account (`merged`).
260
+ const keys = new Map<string, string>()
261
+ const merged = new Map<string, { login: string | null; id: number | null; name: string | null }>()
262
+ const add = (counts: Counts, at: number, made: number[]) => counts.set(at, made.map((value, index) => (counts.get(at)?.[index] ?? 0) + value))
263
+ for (const commit of commits) {
264
+ if (commit.parents.length > 1 || commit.committedAt === null || commit.committedAt < first) continue
265
+ const at = Math.floor((commit.committedAt - first) / WEEK_MS)
266
+ const week = weeks[at]
267
+ if (!week) continue
268
+ const [additions, deletions] = [commit.additions ?? 0, commit.deletions ?? 0]
269
+ const weekday = new Date(commit.committedAt).getUTCDay()
270
+ week.days[weekday]!++
271
+ week.dayLines[weekday]![0] += additions
272
+ week.dayLines[weekday]![1] += deletions
273
+ week.commits++
274
+ week.additions += additions
275
+ week.deletions += deletions
276
+ const release = releases.has(commit.sha)
277
+ const agent = !release && withAgent(commit, merges, direct)
278
+ if (release) week.releaseDays[weekday]!++
279
+ if (direct.has(commit.sha)) week.directDays[weekday]!++
280
+ if (agent) week.agentDays[weekday]!++
281
+ const local = new Date(commit.authoredAt != null && commit.authorZone != null ? commit.authoredAt + commit.authorZone * 60_000 : commit.committedAt)
282
+ for (const span of PULSE_WEEKS) if (at >= 52 - span) punches[span]![local.getUTCDay()]![local.getUTCHours()]!++
283
+ const key = commit.authorLogin && commit.authorId != null ? `#${commit.authorId}` : commit.authorLogin ? `@${commit.authorLogin.toLowerCase()}`
284
+ : commit.authorName ? `=${commit.authorName}` : null
285
+ if (!key) continue
286
+ keys.set(commit.sha, key)
287
+ const person = people.get(key) ?? { login: commit.authorLogin, logins: new Set<string>(), id: commit.authorId ?? null, name: commit.authorName,
288
+ weeks: new Map(), days: new Map(), commits: 0 }
289
+ if (commit.authorLogin) person.logins.add(commit.authorLogin.toLowerCase())
290
+ add(person.weeks, week.week, [1, additions, deletions])
291
+ 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])
292
+ person.commits++
293
+ people.set(key, person)
294
+ }
295
+ // A login one account committed as, those of its commits read before ids
296
+ // were that account's; one more did, theirs apart, no one's.
297
+ const accounts = Map.groupBy([...people.values()].filter(person => person.id !== null).flatMap(person => [...person.logins].map(login => ({ login, person }))),
298
+ ({ login }) => login)
299
+ for (const [key, person] of people) {
300
+ const [owner, ...others] = person.login !== null && person.id === null ? accounts.get(person.login.toLowerCase()) ?? [] : []
301
+ if (!owner || others.length > 0) continue
302
+ const account = owner.person
303
+ for (const [week, counts] of person.weeks) add(account.weeks, week, counts)
304
+ for (const [day, counts] of person.days) add(account.days, day, counts)
305
+ account.commits += person.commits
306
+ people.delete(key)
307
+ merged.set(key, account)
308
+ }
309
+ const rows = (counts: Counts) => [...counts].toSorted(([a], [b]) => a - b).map(([at, made]) => [at, ...made])
310
+ const contributors = [...people.values()].toSorted((a, b) => b.commits - a.commits).slice(0, MAX_CONTRIBUTORS).map(({ login, id, name, weeks: theirs, days }) => ({
311
+ login, id, name, weeks: rows(theirs) as [number, number, number, number][], days: rows(days) as [number, number, number, number, number, number, number][] }))
312
+ // Each pushed directly by the contributor its author's commits are counted
313
+ // as, whatever account it named.
314
+ const directCommits = commits.filter(commit => direct.has(commit.sha) && commit.committedAt !== null && commit.committedAt >= first)
315
+ .toSorted((a, b) => b.committedAt! - a.committedAt!).slice(0, DIRECT_LISTED).map((commit): PulseCommit => {
316
+ const { sha, committedAt, subject, authorLogin, authorId, authorName } = commit
317
+ const author = people.get(keys.get(sha) ?? '') ?? merged.get(keys.get(sha) ?? '') ?? { login: authorLogin, id: authorId ?? null, name: authorName }
318
+ const kind = releases.has(sha) ? 'release' : withAgent(commit, merges, direct) ? 'agent' : 'without'
319
+ return { sha, at: committedAt!, subject, login: author.login, id: author.id, name: author.name, kind }
320
+ })
321
+ return { weeks, contributors, punch: punches[52]!, punches, lines: true, directCommits }
322
+ }
323
+
324
+ // The year's statistics as a period of `weeks` weeks shows them: when its
325
+ // commits are made, the period's where they were counted from its commits
326
+ // (`periodPunch`), else the year's or more, as GitHub's statistics count it;
327
+ // the commits pushed to the branch directly, the period's alone.
328
+ export function periodStats(stats: PulseStats, weeks: number) {
329
+ const { punches, directCommits, ...rest } = stats
330
+ const punch = punches?.[weeks]
331
+ const since = stats.weeks.at(-weeks)?.week ?? Number.NEGATIVE_INFINITY
332
+ return { ...rest, punch: punch ?? rest.punch, periodPunch: Boolean(punch), ...directCommits ? { directCommits: directCommits.filter(commit => commit.at >= since) } : {} }
333
+ }
334
+
335
+ // GitHub's statistics of a year past YEAR_COMMITS (repositoryStats) with
336
+ // its `lines` added in, each week's and each contributor's, by login; null
337
+ // while GitHub is counting them, `lines` null.
338
+ export function withLines(stats: PulseStats, lines: PulseLines | null): PulseStats {
339
+ if (!lines) return { ...stats, lines: null }
340
+ if (!lines.lines) return { ...stats, lines: false }
341
+ const weekly = new Map(lines.weeks.map(([week, additions, deletions]) => [week, [additions, deletions]]))
342
+ const theirs = new Map(lines.contributors.map(person => [person.login, new Map(person.weeks.map(([week, additions, deletions]) => [week, [additions, deletions]]))]))
343
+ return {
344
+ ...stats,
345
+ weeks: stats.weeks.map(week => ({ ...week, additions: weekly.get(week.week)?.[0] ?? 0, deletions: weekly.get(week.week)?.[1] ?? 0 })),
346
+ contributors: stats.contributors.map(person => ({ ...person, weeks: person.weeks.map(([week, made]) => {
347
+ const added = person.login ? theirs.get(person.login)?.get(week) : undefined
348
+ return [week, made, added?.[0] ?? 0, added?.[1] ?? 0] as [number, number, number, number]
349
+ }) })),
350
+ lines: true,
351
+ }
352
+ }
353
+
354
+ // Where a period of `weeks` weeks starts, as GitHub's statistics count weeks:
355
+ // the Sunday (UTC) that many weeks back, this week the last of them.
356
+ export function pulseSince(weeks: number, now: number) {
357
+ const today = Math.floor(now / DAY_MS) * DAY_MS
358
+ const sunday = today - new Date(today).getUTCDay() * DAY_MS
359
+ return sunday - (weeks - 1) * WEEK_MS
360
+ }
361
+
362
+ const SEARCH = (name: string, fragment: string, first = 0) => `${name}: search(type: ISSUE, query: $${name}, first: ${Math.max(first, 1)}) {
363
+ issueCount${first > 0 ? ` nodes { ...${fragment} }` : ''}
364
+ }`
365
+ const ACTIVITY_QUERY = `query($merged: String!, $mergedBefore: String!, $opened: String!, $openedBefore: String!,
366
+ $closed: String!, $closedBefore: String!, $created: String!, $createdBefore: String!) {
367
+ ${SEARCH('merged', 'pull', SEARCHED)}
368
+ ${SEARCH('mergedBefore', 'pull')}
369
+ ${SEARCH('opened', 'pull', LISTED)}
370
+ ${SEARCH('openedBefore', 'pull')}
371
+ ${SEARCH('closed', 'issue', SEARCHED)}
372
+ ${SEARCH('closedBefore', 'issue')}
373
+ ${SEARCH('created', 'issue', LISTED)}
374
+ ${SEARCH('createdBefore', 'issue')}
375
+ }
376
+ fragment pull on PullRequest { number title pullState: state isDraft createdAt mergedAt author { ${ACTOR} } }
377
+ fragment issue on Issue { number title issueState: state stateReason createdAt closedAt author { ${ACTOR} } }`
378
+
379
+ type SearchNode = { number?: unknown; title?: unknown; pullState?: unknown; issueState?: unknown; isDraft?: unknown; stateReason?: unknown
380
+ createdAt?: unknown; mergedAt?: unknown; closedAt?: unknown; author?: GraphAccount } | null
381
+ type Search = { issueCount?: unknown; nodes?: SearchNode[] } | null | undefined
382
+ const REASONS: Record<string, string> = { COMPLETED: 'completed', NOT_PLANNED: 'not-planned', DUPLICATE: 'duplicate' }
383
+
384
+ // An issue or pull request a search lists, as the popup's preview of it
385
+ // takes one, `at` when it was merged, opened or closed.
386
+ function listed(node: SearchNode, at: unknown) {
387
+ if (!node || !Number.isSafeInteger(node.number) || (node.number as number) <= 0) return null
388
+ const title = typeof node.title === 'string' ? node.title.trim().slice(0, MAX_TITLE) : ''
389
+ const author = actorOf(node.author)
390
+ const common = { number: node.number as number, title, author: author.login, authorId: author.id, at: timeOf(at), createdAt: timeOf(node.createdAt) }
391
+ return node.pullState === undefined
392
+ ? { ...common, kind: 'issues', state: node.issueState === 'OPEN' ? 'open' : 'closed', reason: REASONS[String(node.stateReason)] ?? null }
393
+ : { ...common, kind: 'pulls', state: node.pullState === 'MERGED' ? 'merged' : node.pullState === 'OPEN' ? 'open' : 'closed', draft: node.isDraft === true }
394
+ }
395
+
396
+ // The pull requests merged and opened and the issues closed and opened since
397
+ // the start of the week `weeks` weeks back (`since`), and over the same days
398
+ // of the week `weeks` weeks earlier (`previous`, its count: a week's Sunday to
399
+ // Thursday beside the week before's, not the days just before, whose days of
400
+ // the week differ), the latest of each listed, as many as LISTED: those
401
+ // opened as GitHub sorts them, those merged or closed the latest of the
402
+ // SEARCHED most recently updated, which are the latest of all where the
403
+ // period holds no more, or the last listed was merged or closed after the
404
+ // SEARCHED-th was last updated. A search counts by the day (UTC). Narrowed
405
+ // (`narrowing`), those of its days from `from` to `to` (both days, UTC;
406
+ // none, on), and those its `authors` (GitHub logins) opened, any of them.
407
+ export async function repositoryActivity(fullName: string, token: string, weeks: number, now: number = Date.now(), fetchImpl: typeof fetch = globalThis.fetch,
408
+ narrowing: PulseNarrowing | null = null) {
409
+ const since = pulseSince(weeks, now)
410
+ const back = weeks * WEEK_MS
411
+ const day = (at: number) => new Date(at).toISOString().slice(0, 10)
412
+ const current = narrowing?.to == null ? `>=${day(narrowing?.from ?? since)}` : `${day(narrowing.from)}..${day(narrowing.to)}`
413
+ const previous = `${day(since - back)}..${day(now - back)}`
414
+ const repo = `repo:${fullName}`
415
+ // An app's login (`name[bot]`) is searched for as GitHub names its app.
416
+ const by = (narrowing?.authors ?? []).map(login => ` author:${login.endsWith('[bot]') ? `app/${login.slice(0, -5)}` : login}`).join('')
417
+ const asked = (what: string, range: string, sort: string) => `${repo} ${what}${range}${by} sort:${sort}`
418
+ const kinds = {
419
+ merged: ['is:pr is:merged merged:', 'updated-desc'], opened: ['is:pr created:', 'created-desc'],
420
+ closed: ['is:issue is:closed closed:', 'updated-desc'], created: ['is:issue created:', 'created-desc'],
421
+ } as const
422
+ const variables = Object.fromEntries(Object.entries(kinds).flatMap(([name, [what, sort]]) => [[name, asked(what, current, sort)],
423
+ [`${name}Before`, asked(what, previous, sort)]]))
424
+ const data = await githubGraphql(ACTIVITY_QUERY, variables, token, fetchImpl) as Record<string, Search> | null
425
+ const at = { merged: 'mergedAt', opened: 'createdAt', closed: 'closedAt', created: 'createdAt' } as const
426
+ const told = Object.fromEntries((Object.keys(kinds) as (keyof typeof kinds)[]).map(name => [name, {
427
+ count: count(data?.[name]?.issueCount),
428
+ previous: count(data?.[`${name}Before`]?.issueCount),
429
+ items: (data?.[name]?.nodes ?? []).flatMap(node => listed(node, node?.[at[name]]) ?? []).toSorted((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, LISTED),
430
+ }]))
431
+ return { since, previousSince: since - back, ...told }
432
+ }
@@ -0,0 +1,165 @@
1
+ // A repository as a repository risk scan reads it of GitHub
2
+ // (repository-risk.ts): opened at its default branch's head, its lockfiles
3
+ // of npm's ecosystem listed (findRepoLockfiles), those of its `paths` alone,
4
+ // each read for its packages (@preventive/lockfile), and access to it checked
5
+ // again before they are told (`recheck`); and its Dependabot alerts, open or dismissed, of
6
+ // npm's, where the source asks them (dependabot-alerts.ts), asked meanwhile
7
+ // and never kept, a failure to have them its own, and its GitHub Actions
8
+ // (repository-actions.ts). A listing is kept by the commit it was listed at,
9
+ // its Actions' files too, and a lockfile's packages by its blob: in the
10
+ // instance's memory, and the source's `listings`, `actionFiles` and
11
+ // `lockfiles` for SCAN_READ_KEPT_MS, so that only opening a repository and
12
+ // checking access to it again ask GitHub where all else is kept.
13
+ import type { LockfileFormat, RepoLockfile } from '@preventive/upstream/github.js'
14
+ import type { AlertsRead } from './dependabot-access.ts'
15
+ import type { DependabotAlert } from './dependabot-alerts.ts'
16
+ import { type LockedPackage, isTestFixture, lockedPackages } from './locked-packages.ts'
17
+ import type { Lru } from './lru.ts'
18
+ import type { KeptStore } from './npm-kept.ts'
19
+ import { type WorkflowsRead, lastingRead, readActions } from './repository-actions.ts'
20
+ import { type RiskFailure, failureOf } from './repository-risk-assess.ts'
21
+ import type { RepositoryRiskLine, RepositoryRiskRepo } from './repository-risk.ts'
22
+
23
+ // The formats of npm's ecosystem, as findRepoLockfiles names them, and those
24
+ // of them read here.
25
+ const NPM_LOCKFILE_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno'])
26
+ const READ_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn'])
27
+
28
+ // How long the source's `listings` and `lockfiles` are read for: what
29
+ // GitHub has at a commit or a blob doesn't change, how it is read here may.
30
+ export const SCAN_READ_KEPT_MS = 24 * 60 * 60_000
31
+
32
+ export type ListedLockfile = Pick<RepoLockfile, 'path' | 'format' | 'sha'>
33
+ export type LockfileRead = { packages: LockedPackage[] } | RiskFailure
34
+ // A repository's Dependabot alerts, those of its `paths` alone, with access to
35
+ // them to check again before they are told (`recheck`), or why they couldn't
36
+ // be had.
37
+ export type DependabotRead = { alerts: DependabotAlert[]; recheck?(): Promise<void> } | RiskFailure
38
+ type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
39
+ export type RepositoryFailure = Extract<RepositoryRiskLine, { type: 'repo'; error: string }>
40
+
41
+ // A repository opened at its default branch's head (`commit`): its lockfiles,
42
+ // as findRepoLockfiles lists them, each with its blob's id, and a file's
43
+ // text; `recheck` checks access to it again, throwing where it is gone; and
44
+ // `dependabot`, where asked, its Dependabot alerts of npm's, open or dismissed, as GitHub
45
+ // has them now, with access to them to check again before they are told.
46
+ export interface OpenedRepository {
47
+ commit: string
48
+ lockfiles(): Promise<ListedLockfile[]>
49
+ file(path: string): Promise<string>
50
+ recheck(): Promise<void>
51
+ dependabot?(): Promise<AlertsRead>
52
+ // Its workflows' paths, none where it has none.
53
+ workflows?(): Promise<string[]>
54
+ }
55
+
56
+ // What a repository is read with: the scan's source and caches, and its
57
+ // signal.
58
+ export interface RepositoryReading<R extends RepositoryRiskRepo> {
59
+ source: { open(repo: R): Promise<OpenedRepository>; listings?: KeptStore<ListedLockfile[]>; lockfiles?: KeptStore<LockfileRead>; actionFiles?: KeptStore<WorkflowsRead> }
60
+ caches: { listings: Lru<ListedLockfile[]>; lockfiles: Lru<LockfileRead>; actionFiles: Lru<WorkflowsRead> }
61
+ signal: AbortSignal
62
+ }
63
+
64
+ // A repository read: its commit, its lockfiles' lines, each with its blob's
65
+ // id, its Dependabot alerts where asked, and access to it to check again just
66
+ // before they are told (OpenedRepository `recheck`), as GitHub may have
67
+ // withdrawn it meanwhile.
68
+ export interface RepositoryRead {
69
+ commit: string
70
+ lockfiles: { sha: string; line: LockfileLine }[]
71
+ dependabot: DependabotRead | null
72
+ // Its Actions' files, where it lists its workflows.
73
+ actions: WorkflowsRead | null
74
+ recheck(): Promise<void>
75
+ }
76
+
77
+ // Whether a lockfile at `path` is of `paths` (RepositoryRiskRepo).
78
+ function lockfileOf(path: string, paths: readonly string[] | undefined) {
79
+ const dir = path.slice(0, Math.max(path.lastIndexOf('/'), 0))
80
+ return !paths || paths.some(scope => dir === '' || dir === scope || dir.startsWith(`${scope}/`) || scope.startsWith(`${dir}/`))
81
+ }
82
+
83
+ // What is kept by `key`: in this instance's memory, else in `store` within
84
+ // SCAN_READ_KEPT_MS, else `read` and kept in both, where it is to be.
85
+ async function keptRead<T>(memory: Lru<T>, store: KeptStore<T> | undefined, key: string, read: () => Promise<T>, keep = (_value: T) => true) {
86
+ const known = memory.get(key)
87
+ if (known !== undefined) return known
88
+ const kept = await store?.read(key)
89
+ if (kept && Date.now() - kept.at < SCAN_READ_KEPT_MS) { memory.set(key, kept.value); return kept.value }
90
+ const value = await read()
91
+ if (!keep(value)) return value
92
+ memory.set(key, value)
93
+ await store?.write(key, value, Date.now())
94
+ return value
95
+ }
96
+
97
+ // A lockfile's line, its packages or why they couldn't be had: as kept by its
98
+ // blob, else read from GitHub and kept.
99
+ async function readLockfile({ source, caches }: RepositoryReading<RepositoryRiskRepo>, repo: number, opened: OpenedRepository,
100
+ { path, format, sha }: ListedLockfile): Promise<LockfileLine> {
101
+ const at = { type: 'lockfile', repo, path, format } as const
102
+ if (!READ_FORMATS.has(format)) return { ...at, error: 'unsupported-format' }
103
+ try {
104
+ return { ...at, ...await keptRead(caches.lockfiles, source.lockfiles, `${format}\0${sha}`, async (): Promise<LockfileRead> => {
105
+ const text = await opened.file(path)
106
+ try { return { packages: lockedPackages(format, text) } } catch (error) {
107
+ return { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
108
+ }
109
+ }) }
110
+ } catch (error) { return { ...at, ...failureOf(error) } }
111
+ }
112
+
113
+ // A repository opened and its lockfiles of npm's ecosystem listed, as kept
114
+ // for its commit or else from GitHub, those of its `paths` alone; its line
115
+ // telling why where either failed; null where the scan stopped.
116
+ async function listLockfiles<R extends RepositoryRiskRepo>(reading: RepositoryReading<R>, repo: R) {
117
+ let doing = 'opening it'
118
+ try {
119
+ const opened = await reading.source.open(repo)
120
+ if (reading.signal.aborted) return null
121
+ doing = 'listing its lockfiles'
122
+ const listed = await keptRead(reading.caches.listings, reading.source.listings, `${repo.repoId}@${opened.commit}`, () => opened.lockfiles())
123
+ const lockfiles = listed.filter(({ path, format }) => NPM_LOCKFILE_FORMATS.has(format) && lockfileOf(path, repo.paths))
124
+ return { opened, lockfiles }
125
+ } catch (error) {
126
+ return reading.signal.aborted ? null : { type: 'repo', id: repo.repoId, ...failureOf(error, doing) } as const
127
+ }
128
+ }
129
+
130
+ // A repository's Dependabot alerts of its `paths` (RepositoryRiskRepo), as
131
+ // its lockfiles are, or why they couldn't be had; null where they aren't
132
+ // asked.
133
+ async function readDependabot(opened: OpenedRepository, paths: readonly string[] | undefined): Promise<DependabotRead | null> {
134
+ if (!opened.dependabot) return null
135
+ try {
136
+ const { alerts, recheck } = await opened.dependabot()
137
+ return { alerts: alerts.filter(alert => lockfileOf(alert.manifest, paths)), recheck }
138
+ } catch (error) {
139
+ return failureOf(error, 'asking its Dependabot alerts')
140
+ }
141
+ }
142
+
143
+ // A repository read: its lockfiles listed and read, its Dependabot alerts
144
+ // asked and its Actions read meanwhile; its line telling why where listing
145
+ // failed; null where the scan stopped. A test's
146
+ // fixture or example its reader refuses (isTestFixture) is left out, as a
147
+ // test may keep one so on purpose.
148
+ export async function readRepository<R extends RepositoryRiskRepo>(reading: RepositoryReading<R>, repo: R): Promise<RepositoryRead | RepositoryFailure | null> {
149
+ const listed = await listLockfiles(reading, repo)
150
+ if (!listed || 'type' in listed) return listed
151
+ const alerting = readDependabot(listed.opened, repo.paths)
152
+ const { opened } = listed, { workflows } = opened
153
+ const acting = workflows ? keptRead(reading.caches.actionFiles, reading.source.actionFiles, `${repo.repoId}@${opened.commit}`,
154
+ () => readActions({ workflows, file: path => opened.file(path) }), lastingRead) : Promise.resolve(null)
155
+ acting.catch(() => {})
156
+ const lockfiles: RepositoryRead['lockfiles'] = []
157
+ for (const lockfile of listed.lockfiles) {
158
+ if (reading.signal.aborted) return null
159
+ const line = await readLockfile(reading, repo.repoId, listed.opened, lockfile)
160
+ if (!('error' in line && line.error === 'lockfile-unreadable' && isTestFixture(line.path))) lockfiles.push({ sha: lockfile.sha, line })
161
+ }
162
+ const dependabot = await alerting
163
+ const actions = await acting
164
+ return reading.signal.aborted ? null : { commit: listed.opened.commit, lockfiles, dependabot, actions, recheck: () => listed.opened.recheck() }
165
+ }