@preventive/triage 1.0.0-alpha.43 → 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 (186) hide show
  1. package/common/bundle-compare-diff.js +2 -167
  2. package/common/bundle-compare-versions.js +172 -0
  3. package/common/bundle-comparison.d.ts +8 -0
  4. package/common/bundle-comparison.js +62 -0
  5. package/common/bundle-sources.js +20 -1
  6. package/common/default-scan-models.ts +1 -1
  7. package/common/dependency-reviews.d.ts +0 -5
  8. package/common/dependency-reviews.js +0 -17
  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 +91 -10
  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 +11 -0
  17. package/common/workspace-app.js +19 -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-5DHL5NG3.js +2 -0
  23. package/out/chunk-6HNXNIQY.js +52 -0
  24. package/out/chunk-7C7DWL7I.js +1 -0
  25. package/out/chunk-AFLZH7WG.js +1 -0
  26. package/out/chunk-BC2UCLFJ.js +1 -0
  27. package/out/chunk-C4Z6ZBXV.js +6 -6
  28. package/out/chunk-GDPCLSOS.js +2 -0
  29. package/out/chunk-GO7FT4OD.js +1 -0
  30. package/out/chunk-HMAJDVXN.js +1 -0
  31. package/out/chunk-I2MDGSQX.js +3 -0
  32. package/out/chunk-IYPY47MC.js +1 -0
  33. package/out/chunk-IZ7POWI3.js +18 -0
  34. package/out/chunk-JCIIBLKR.js +1 -0
  35. package/out/chunk-NPW52BM2.js +17 -0
  36. package/out/chunk-NRPC74AM.js +1 -1
  37. package/out/chunk-NV7ES4EW.js +1 -0
  38. package/out/chunk-OKRLLE5I.js +17 -0
  39. package/out/chunk-OOJM4CTU.js +1 -1
  40. package/out/chunk-PNHBZGDD.js +1 -1
  41. package/out/chunk-PTGDYLNS.js +36 -0
  42. package/out/chunk-PXK7EU4V.js +1 -0
  43. package/out/chunk-Q6MVYGYV.js +3 -0
  44. package/out/chunk-QB4CBOX2.js +1 -0
  45. package/out/chunk-QRD6GIG5.js +229 -0
  46. package/out/chunk-R3NZFXVR.js +1 -0
  47. package/out/chunk-R5O6FAW4.js +1 -1
  48. package/out/chunk-SXV2T5C4.js +80 -0
  49. package/out/chunk-TUII34K2.js +1 -1
  50. package/out/chunk-VFWMQ3LG.js +2 -0
  51. package/out/chunk-VH4XMZIW.js +2 -0
  52. package/out/chunk-WFYLMF6Z.js +10 -0
  53. package/out/chunk-WI7Z5A2T.js +6 -0
  54. package/out/chunk-WQ74ZBZF.js +7 -0
  55. package/out/chunk-XVBEOGOS.js +3 -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 +31 -31
  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 +55 -6
  71. package/risk/actions/findings.js +156 -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 +165 -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 +68 -1
  85. package/risk/index.js +6 -2
  86. package/risk/npm/assess.js +13 -8
  87. package/risk/npm/compare.js +34 -10
  88. package/risk/npm/dependencies.js +11 -0
  89. package/risk/npm/files.js +413 -2
  90. package/risk/npm/tarball.js +56 -73
  91. package/server-managed/agent-merges.ts +198 -0
  92. package/server-managed/app-agents.ts +30 -0
  93. package/server-managed/avatar-store.ts +1 -0
  94. package/server-managed/commit-coauthors.ts +83 -0
  95. package/server-managed/commit-lockfiles.ts +62 -0
  96. package/server-managed/comparison.ts +41 -0
  97. package/server-managed/db-methods.ts +29 -10
  98. package/server-managed/db-neon.ts +30 -2
  99. package/server-managed/db-schema.ts +9 -2
  100. package/server-managed/db.ts +7 -0
  101. package/server-managed/dependabot-access.ts +60 -0
  102. package/server-managed/dependabot-alerts.ts +185 -0
  103. package/server-managed/dependency-review-comments.ts +97 -0
  104. package/server-managed/dependency-reviews.ts +65 -15
  105. package/server-managed/github-accounts.ts +33 -0
  106. package/server-managed/github-actions.ts +71 -0
  107. package/server-managed/github-app.ts +23 -9
  108. package/server-managed/github-errors.ts +2 -0
  109. package/server-managed/github-link-oauth.ts +69 -0
  110. package/server-managed/github-links.ts +204 -0
  111. package/server-managed/github-oauth.ts +5 -2
  112. package/server-managed/github-users.ts +30 -0
  113. package/server-managed/http.ts +1219 -81
  114. package/server-managed/index.ts +6 -2
  115. package/server-managed/locked-packages.ts +48 -16
  116. package/server-managed/lockfile-scope.ts +49 -0
  117. package/server-managed/npm-delta-worker.js +23 -0
  118. package/server-managed/npm-delta.ts +173 -0
  119. package/server-managed/npm-insights.ts +20 -11
  120. package/server-managed/npm-kept.ts +51 -4
  121. package/server-managed/npm-loads.ts +20 -13
  122. package/server-managed/npm-packages.ts +24 -64
  123. package/server-managed/pretty-print.ts +14 -9
  124. package/server-managed/public-workspace.ts +26 -8
  125. package/server-managed/pull-comment-reviews.ts +161 -0
  126. package/server-managed/pull-dependencies.ts +280 -0
  127. package/server-managed/pull-dependency-reviews.ts +318 -0
  128. package/server-managed/pull-reviews.ts +205 -0
  129. package/server-managed/release-commits.ts +84 -0
  130. package/server-managed/repository-actions.ts +209 -0
  131. package/server-managed/repository-agents.ts +220 -0
  132. package/server-managed/repository-browser.ts +54 -13
  133. package/server-managed/repository-changes.ts +284 -0
  134. package/server-managed/repository-code.ts +52 -0
  135. package/server-managed/repository-dependency-reviews.ts +135 -0
  136. package/server-managed/repository-history.ts +445 -0
  137. package/server-managed/repository-issues.ts +181 -0
  138. package/server-managed/repository-pulse.ts +432 -0
  139. package/server-managed/repository-risk-read.ts +165 -0
  140. package/server-managed/repository-risk.ts +116 -106
  141. package/server-managed/reviewed-dependencies.ts +149 -0
  142. package/server-managed/session.ts +9 -5
  143. package/server-managed/sql.ts +1 -1
  144. package/server-managed/storage-stores.ts +5 -0
  145. package/server-managed/team-app.ts +54 -14
  146. package/server-managed/workspace-shares.ts +4 -1
  147. package/server.ts +11 -0
  148. package/out/chunk-2KBF5TZ7.js +0 -2
  149. package/out/chunk-35VE6CDH.js +0 -2
  150. package/out/chunk-3BZ7JERB.js +0 -1
  151. package/out/chunk-4F6JSF57.js +0 -3
  152. package/out/chunk-4XCDFGDP.js +0 -10
  153. package/out/chunk-7RU5EDRO.js +0 -226
  154. package/out/chunk-A7M4GV7M.js +0 -1
  155. package/out/chunk-CLPKNNC7.js +0 -51
  156. package/out/chunk-CMQA4DM6.js +0 -1
  157. package/out/chunk-CVVJAXVY.js +0 -2
  158. package/out/chunk-DIJJNTRH.js +0 -10
  159. package/out/chunk-GJ4IUCNS.js +0 -1
  160. package/out/chunk-GNXOKIGP.js +0 -80
  161. package/out/chunk-GZ4MS3TE.js +0 -13
  162. package/out/chunk-IU3CCINR.js +0 -3
  163. package/out/chunk-K3X5EJBQ.js +0 -2
  164. package/out/chunk-KEX2ATEW.js +0 -2
  165. package/out/chunk-KYCNJVB7.js +0 -1
  166. package/out/chunk-LAAB5SZT.js +0 -2
  167. package/out/chunk-NWZ4PVPN.js +0 -1
  168. package/out/chunk-OSIMGFUF.js +0 -1
  169. package/out/chunk-RFZQQXSG.js +0 -17
  170. package/out/chunk-RSHMVYNS.js +0 -1
  171. package/out/chunk-TZ5KB2Y5.js +0 -10
  172. package/out/chunk-UNTRGE26.js +0 -2
  173. package/out/chunk-UXNUAIIC.js +0 -33
  174. package/out/chunk-UYAXVRVL.js +0 -1
  175. package/out/chunk-V3ME6FI5.js +0 -2
  176. package/out/chunk-V5O7M2YT.js +0 -1
  177. package/out/chunk-VIHGO77Y.js +0 -1
  178. package/out/chunk-VQJCXGIY.js +0 -7
  179. package/out/chunk-W4J3MGLB.js +0 -1
  180. package/out/chunk-WGG7KQUH.js +0 -1
  181. package/out/chunk-WZDTZE64.js +0 -1
  182. package/out/chunk-XOCAPDCM.js +0 -1
  183. package/out/chunk-XYFXXB2G.js +0 -10
  184. package/out/chunk-YVZH5SHP.js +0 -1
  185. package/out/chunk-ZBBOBQ43.js +0 -1
  186. package/out/chunk-ZMQQCGF6.js +0 -2
@@ -0,0 +1,205 @@
1
+ // What an open pull request's reviews say, as the code viewer's Pull requests
2
+ // tab and a pull request's popup show it (repository-issues.ts): who
3
+ // reviewed it and how, each by their latest review and the commit it was on,
4
+ // its author's own aside (the replies GitHub makes reviews of in a thread);
5
+ // whose review is requested, and how many more than those read; its review
6
+ // threads open and resolved, the latest read (open ones are, mostly, those
7
+ // lately opened), the gravest open one by the P0–P3 badge Codex marks a
8
+ // finding with; and
9
+ // Codex's own, which it keeps in a comment of its own (its summary, edited in
10
+ // place) rather than in reviews, reviewing only where it finds something:
11
+ // the commit its code review and its security review were last on, either
12
+ // one still running, asked for again since, or failed since. A review an
13
+ // agentic workflow (gh-aw) posted, as GitHub Actions' bot, names its
14
+ // workflow by the marker gh-aw leaves (`<!-- gh-aw-workflow-id: NAME -->`), in
15
+ // the popup, which reads reviews' bodies.
16
+ import { githubAgent } from '../common/managed/github-agents.ts'
17
+ import { ACTOR, type GraphAccount, actorOf } from './github-accounts.ts'
18
+
19
+ const REVIEWERS = 20
20
+ const REQUESTS = 10
21
+ // The threads the Pull requests tab counts, and those a popup lists.
22
+ export const TILE_THREADS = 50
23
+ export const ITEM_THREADS = 100
24
+ // The comments read for Codex's: its summary among the first, posted as the
25
+ // pull request opens, or among the last, where it was first asked later;
26
+ // asking it again, and its failures, among the last.
27
+ const FIRST_COMMENTS = 5
28
+ const LAST_COMMENTS = 20
29
+ const MAX_TITLE = 200
30
+ const MAX_PATH = 1000
31
+ const COMMIT = /^[a-f\d]{40}$/u
32
+
33
+ // The fields a pull request's reviews are read by, its threads' places and
34
+ // links with them for its popup (`detail`). A team requested is no reviewer
35
+ // read, but one of those requested GitHub names no one for: reading a team
36
+ // needs its organization's Members permission, and a query reading one
37
+ // without it fails whole (as one naming an EnterpriseTeam, which GitHub's
38
+ // docs name and its API knows none of, does).
39
+ export const reviewFields = (threads: number, detail: boolean) => `headRefOid
40
+ latestReviews(first: ${REVIEWERS}) { totalCount nodes { author { ${ACTOR} } state submittedAt commit { oid }${detail ? ' body' : ''} } }
41
+ reviewRequests(first: ${REQUESTS}) { totalCount nodes { requestedReviewer { __typename ... on User { login databaseId } ... on Bot { login databaseId }
42
+ ... on Mannequin { login databaseId } } } }
43
+ reviewThreads(last: ${threads}) { totalCount nodes { isResolved isOutdated${detail ? ' path line originalLine' : ''}
44
+ comments(first: 1) { nodes { author { ${ACTOR} } body${detail ? ' url' : ''} } } } }
45
+ firstComments: comments(first: ${FIRST_COMMENTS}) { nodes { author { ${ACTOR} } body updatedAt } }
46
+ lastComments: comments(last: ${LAST_COMMENTS}) { nodes { author { ${ACTOR} } body createdAt updatedAt } }`
47
+
48
+ // The fields read for reviews that GitHub may refuse alone, as it refuses an
49
+ // App a pull request's checks: their pull request then shows no reviews.
50
+ const REVIEW_PATHS = new Set(['latestReviews', 'reviewRequests', 'reviewThreads', 'firstComments', 'lastComments'])
51
+ export const reviewsRefused = (error: { type?: unknown; path?: unknown } | null | undefined) => error?.type === 'FORBIDDEN' && Array.isArray(error.path)
52
+ && error.path.some(part => REVIEW_PATHS.has(part as string))
53
+
54
+ type Comment = { author?: GraphAccount; body?: unknown; updatedAt?: unknown; createdAt?: unknown } | null
55
+ type Thread = { isResolved?: unknown; isOutdated?: unknown; path?: unknown; line?: unknown; originalLine?: unknown
56
+ comments?: { nodes?: (Comment & { url?: unknown })[] } | null } | null
57
+ export type ReviewNode = {
58
+ headRefOid?: unknown
59
+ latestReviews?: { totalCount?: unknown; nodes?: ({ author?: GraphAccount; state?: unknown; submittedAt?: unknown; commit?: { oid?: unknown } | null; body?: unknown } | null)[] } | null
60
+ reviewRequests?: { totalCount?: unknown; nodes?: ({ requestedReviewer?: { __typename?: unknown; login?: unknown; databaseId?: unknown } | null } | null)[] } | null
61
+ reviewThreads?: { totalCount?: unknown; nodes?: Thread[] } | null
62
+ firstComments?: { nodes?: Comment[] } | null
63
+ lastComments?: { nodes?: Comment[] } | null
64
+ }
65
+
66
+ const STATES: Record<string, string> = { APPROVED: 'approved', CHANGES_REQUESTED: 'changes', COMMENTED: 'commented', DISMISSED: 'dismissed' }
67
+ const CODEX_STATES: Record<string, string> = { completed: 'done', running: 'running', queued: 'running', pending: 'running', in_progress: 'running', failed: 'failed', error: 'failed' }
68
+ const SEVERITY = /!\[(P[0-3]) Badge\]/u
69
+ const SUMMARY = /<!--\s*codex-review-state:v1\s+(\{[^]*?\})\s*-->/u
70
+ // Asking it to review again reruns its code review alone, never its security review.
71
+ const ASKED = /^@codex\s+review\b/iu
72
+ const FAILED = /^Codex Review: Something went wrong/u
73
+ const WORKFLOW = /<!--\s*gh-aw-workflow-id:\s*([\w.-]{1,100})\s*-->/u
74
+
75
+ const timeOf = (value: unknown) => {
76
+ const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
77
+ return Number.isSafeInteger(at) ? at : null
78
+ }
79
+ const commitOf = (value: unknown) => typeof value === 'string' && COMMIT.test(value.toLowerCase()) ? value.toLowerCase() : null
80
+ const textOf = (value: unknown) => typeof value === 'string' ? value : ''
81
+ const isCodex = (account: GraphAccount) => {
82
+ const { login, id } = actorOf(account)
83
+ return login !== null && githubAgent(login, id) === 'codex'
84
+ }
85
+
86
+ // A finding's gravity, by its badge, `p0` the gravest; null for none.
87
+ const severityOf = (body: string) => SEVERITY.exec(body.slice(0, 500))?.[1]?.toLowerCase() ?? null
88
+ // A thread's title: its first comment's first line with words, as written,
89
+ // but for Codex's badge and emphasis.
90
+ function titleOf(body: string) {
91
+ for (const line of body.split('\n', 6)) {
92
+ const words = line.replaceAll(/!\[[^\]]*\]\([^)]*\)|<\/?sub>|\*\*|__/gu, '').trim()
93
+ if (words) return words.slice(0, MAX_TITLE)
94
+ }
95
+ return ''
96
+ }
97
+
98
+ // Codex's review of one kind, as its summary names it: the commit it was
99
+ // last on and how it stands.
100
+ function codexReview(value: unknown) {
101
+ const review = value && typeof value === 'object' ? value as { commit_sha?: unknown; status?: unknown } : null
102
+ if (!review) return null
103
+ return { commit: commitOf(review.commit_sha), status: CODEX_STATES[String(review.status)] ?? null }
104
+ }
105
+
106
+ // What Codex's summary says of its reviews, else null.
107
+ function summaryState(body: string): { code_review?: unknown; security_review?: unknown } | null {
108
+ try {
109
+ const value: unknown = JSON.parse(SUMMARY.exec(body)?.[1] ?? 'null')
110
+ return value && typeof value === 'object' ? value : null
111
+ } catch { return null }
112
+ }
113
+
114
+ // Codex's reviews, from its summary, else none: its account, as the summary
115
+ // names it; whether it was asked again since the summary last changed
116
+ // (`@codex review`), and whether it failed since it was last asked.
117
+ function codexOf(node: ReviewNode) {
118
+ const summary = [...node.firstComments?.nodes ?? [], ...node.lastComments?.nodes ?? []]
119
+ .find(comment => comment && isCodex(comment.author) && SUMMARY.test(textOf(comment.body)))
120
+ if (!summary) return null
121
+ const state = summaryState(textOf(summary.body))
122
+ if (!state) return null
123
+ const since = timeOf(summary.updatedAt) ?? 0
124
+ const later = (node.lastComments?.nodes ?? []).filter(comment => comment && (timeOf(comment.createdAt) ?? 0) > since) as NonNullable<Comment>[]
125
+ const at = (comment: NonNullable<Comment> | undefined) => comment ? timeOf(comment.createdAt) ?? 0 : null
126
+ const asked = at(later.findLast(comment => !isCodex(comment.author) && ASKED.test(textOf(comment.body).trim())))
127
+ const failed = at(later.findLast(comment => isCodex(comment.author) && FAILED.test(textOf(comment.body).trim())))
128
+ const { login, id } = actorOf(summary.author)
129
+ return { login, id, code: codexReview(state.code_review), security: codexReview(state.security_review),
130
+ requested: asked !== null && !(failed !== null && failed > asked), failed: failed !== null && (asked === null || failed > asked) }
131
+ }
132
+
133
+ type Reviewer = { login: string | null; id: number | null; workflow?: string; state: string | null; requested: boolean; commit: string | null; at: number | null }
134
+
135
+ // The reviewers, each once: by their latest review, its state and commit;
136
+ // whose is requested (again, for one who reviewed), a user, a bot (Copilot's
137
+ // code review, asked for, is its bot's) or an imported account's mannequin.
138
+ // Codex's reviews are its summary's where it has one (`codex`). How many
139
+ // requested GitHub named no one for, a team among them, as `unnamed`,
140
+ // counted with those past the ones read.
141
+ function reviewersOf(node: ReviewNode, author: string | null, codex: boolean) {
142
+ const own = author?.toLowerCase()
143
+ const reviewers: Reviewer[] = (node.latestReviews?.nodes ?? []).flatMap(review => {
144
+ const { login, id } = actorOf(review?.author)
145
+ const state = STATES[String(review?.state)]
146
+ if (!state || (login !== null && login.toLowerCase() === own) || (codex && isCodex(review?.author))) return []
147
+ const workflow = WORKFLOW.exec(textOf(review?.body))?.[1]
148
+ return [{ login, id, ...workflow ? { workflow } : {}, state, requested: false, commit: commitOf(review?.commit?.oid), at: timeOf(review?.submittedAt) }]
149
+ })
150
+ let unnamed = 0
151
+ for (const request of node.reviewRequests?.nodes ?? []) {
152
+ const reviewer = request?.requestedReviewer
153
+ const { login, id } = actorOf(reviewer)
154
+ if (login === null) {
155
+ unnamed++
156
+ continue
157
+ }
158
+ // By their account where both name it, as GitHub may name one account
159
+ // otherwise on its reviews than on its requests (Copilot's code review's
160
+ // bot); else by their login.
161
+ const reviewed = reviewers.find(item => id !== null && item.id !== null ? item.id === id : item.login?.toLowerCase() === login.toLowerCase())
162
+ if (reviewed) reviewed.requested = true
163
+ else reviewers.push({ login, id, state: null, requested: true, commit: null, at: null })
164
+ }
165
+ return { reviewers, unnamed }
166
+ }
167
+
168
+ // Its review threads, the latest read: how many are open and resolved,
169
+ // whether more than those read are, the gravest open one; and, for its popup (`detail`), each
170
+ // open one: its gravity, title, file and line, whether its code changed
171
+ // since, who opened it and where it is on GitHub.
172
+ function threadsOf(node: ReviewNode, detail: boolean) {
173
+ const threads = (node.reviewThreads?.nodes ?? []).filter(Boolean) as NonNullable<Thread>[]
174
+ const open = threads.filter(thread => thread.isResolved !== true)
175
+ const severities = open.map(thread => severityOf(textOf(thread.comments?.nodes?.[0]?.body))).filter(Boolean) as string[]
176
+ const total = node.reviewThreads?.totalCount
177
+ const counted = { open: open.length, resolved: threads.length - open.length,
178
+ more: Number.isSafeInteger(total) && (total as number) > threads.length, severity: severities.toSorted()[0] ?? null }
179
+ if (!detail) return counted
180
+ return { ...counted, list: open.map(thread => {
181
+ const first = thread.comments?.nodes?.[0]
182
+ const body = textOf(first?.body)
183
+ const opener = actorOf(first?.author)
184
+ const line = Number.isSafeInteger(thread.line) ? thread.line : Number.isSafeInteger(thread.originalLine) ? thread.originalLine : null
185
+ const url = typeof first?.url === 'string' && first.url.startsWith('https://github.com/') ? first.url : null
186
+ return { severity: severityOf(body), title: titleOf(body), path: typeof thread.path === 'string' ? thread.path.slice(0, MAX_PATH) : null, line,
187
+ outdated: thread.isOutdated === true, author: opener.login, authorId: opener.id, url }
188
+ }) }
189
+ }
190
+
191
+ // A pull request's reviews (`author` its author's login), for its tile or,
192
+ // with its threads listed, its popup (`detail`); null where none was read, or
193
+ // GitHub refused its reviews, requests or threads (reviewsRefused), rather
194
+ // than none.
195
+ export function pullReviews(node: ReviewNode, author: string | null, detail = false) {
196
+ if (node.headRefOid === undefined || node.latestReviews === null || node.reviewRequests === null || node.reviewThreads === null) return null
197
+ const codex = codexOf(node)
198
+ // Those past the reviews and the requests read, as many as GitHub counts,
199
+ // apart: a review unread may be on the latest commit, a request never is.
200
+ const past = (connection: { totalCount?: unknown; nodes?: unknown[] } | null | undefined) => Number.isSafeInteger(connection?.totalCount)
201
+ ? Math.max(0, (connection!.totalCount as number) - (connection!.nodes?.length ?? 0)) : 0
202
+ const { reviewers, unnamed } = reviewersOf(node, author, codex !== null)
203
+ return { head: commitOf(node.headRefOid), reviewers, moreReviews: past(node.latestReviews), moreRequests: past(node.reviewRequests) + unnamed,
204
+ threads: threadsOf(node, detail), codex }
205
+ }
@@ -0,0 +1,84 @@
1
+ // The commits that release a repository, for its Pulse (repository-pulse.ts
2
+ // commitStats): one a tag names that changes no code, only package.json
3
+ // files (at least one, in any directory), the lockfiles beside them
4
+ // (RELEASE_LOCKFILES) and changelogs written in Markdown (RELEASE_CHANGELOG,
5
+ // a changeset's file among them), as `npm version` and a release's tooling
6
+ // leave one. What each commit changes is read as Replay reads it, kept by
7
+ // the commit (repository-changes.ts repositoryChanges).
8
+ import { type ChangesStore, type CommitChanges, repositoryChanges } from './repository-changes.ts'
9
+ import { githubGraphql } from './repository-history.ts'
10
+
11
+ // The tags read, a page a query, the latest commits' first, as many pages as
12
+ // TAG_PAGES; of the commits they name, as many as RELEASES_READ have their
13
+ // files read.
14
+ const TAG_PAGE = 100
15
+ const TAG_PAGES = 5
16
+ const RELEASES_READ = 200
17
+ // An annotated tag's target, its own target in turn, through as many as
18
+ // TAG_DEPTH annotated tags.
19
+ const TAG_DEPTH = 3
20
+ const SHA = /^[a-f\d]{40}$/u
21
+ const RELEASE_LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'])
22
+ const RELEASE_CHANGELOG = /^(?:changelog|changes|history|news|releases?)(?:[._-][^/]*)?\.(?:md|markdown)$/iu
23
+ const CHANGESET = /(?:^|\/)\.changeset\/[^/]+\.md$/iu
24
+
25
+ const tagTarget = (depth: number): string => `target { __typename oid ... on Commit { committedDate }${depth > 0 ? ` ... on Tag { ${tagTarget(depth - 1)} }` : ''} }`
26
+ const TAGS_QUERY = `query($owner: String!, $name: String!, $after: String) {
27
+ repository(owner: $owner, name: $name) {
28
+ refs(refPrefix: "refs/tags/", first: ${TAG_PAGE}, after: $after, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {
29
+ pageInfo { hasNextPage endCursor }
30
+ nodes { ${tagTarget(TAG_DEPTH)} }
31
+ }
32
+ }
33
+ }`
34
+ type TagTarget = { __typename?: unknown; oid?: unknown; committedDate?: unknown; target?: TagTarget | null } | null | undefined
35
+ type TagsPage = { repository?: { refs?: { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: ({ target?: TagTarget } | null)[] } | null } | null }
36
+
37
+ // The commit a tag names, through its annotated tags, and when it was
38
+ // committed; null for one naming no commit (a tree, a blob).
39
+ function taggedCommit(target: TagTarget, depth = TAG_DEPTH): { oid: string; at: number } | null {
40
+ if (target?.__typename === 'Tag' && depth > 0) return taggedCommit(target.target, depth - 1)
41
+ const at = typeof target?.committedDate === 'string' ? Date.parse(target.committedDate) : Number.NaN
42
+ return target?.__typename === 'Commit' && typeof target.oid === 'string' && SHA.test(target.oid) && Number.isSafeInteger(at) ? { oid: target.oid, at } : null
43
+ }
44
+
45
+ // The commits the repository's tags name committed since `since`, the
46
+ // latest first, as many pages of them as TAG_PAGES.
47
+ export async function taggedCommits(fullName: string, token: string, since: number, fetchImpl: typeof fetch = globalThis.fetch): Promise<Set<string>> {
48
+ const [owner, name] = fullName.split('/') as [string, string]
49
+ const tagged = new Set<string>()
50
+ let after: string | null = null
51
+ for (let pages = 0; pages < TAG_PAGES; pages++) {
52
+ const page = await githubGraphql(TAGS_QUERY, { owner, name, after }, token, fetchImpl) as TagsPage | null
53
+ const refs = page?.repository?.refs
54
+ const commits = (refs?.nodes ?? []).flatMap(node => taggedCommit(node?.target) ?? [])
55
+ for (const commit of commits) if (commit.at >= since) tagged.add(commit.oid)
56
+ if (refs?.pageInfo?.hasNextPage !== true || typeof refs.pageInfo.endCursor !== 'string' || commits.some(commit => commit.at < since)) break
57
+ after = refs.pageInfo.endCursor
58
+ }
59
+ return tagged
60
+ }
61
+
62
+ // Whether a path is one a release changes: a package.json, a lockfile beside
63
+ // one, or a changelog in Markdown.
64
+ const releaseFile = (path: string) => {
65
+ const base = path.slice(path.lastIndexOf('/') + 1)
66
+ return base === 'package.json' || RELEASE_LOCKFILES.has(base) || RELEASE_CHANGELOG.test(base) || CHANGESET.test(path)
67
+ }
68
+
69
+ // Whether what a commit changes is a release's: a package.json at least, and
70
+ // nothing a release changes none of, a rename's both paths alike; one
71
+ // GitHub listed only some files of, not.
72
+ export function releaseChanges({ files, more }: CommitChanges): boolean {
73
+ const paths = files.flatMap(([path, status, from]) => status === 'R' ? [path, from] : [path])
74
+ return !more && paths.some(path => path.slice(path.lastIndexOf('/') + 1) === 'package.json') && paths.every(releaseFile)
75
+ }
76
+
77
+ // The commits since `since` that release the repository: those its tags
78
+ // name whose changes are a release's, each read as Replay reads it (`kept`),
79
+ // as many as RELEASES_READ, sorted.
80
+ export async function releaseCommits(fullName: string, token: string, since: number, kept: ChangesStore, fetchImpl: typeof fetch = globalThis.fetch): Promise<string[]> {
81
+ const tagged = [...await taggedCommits(fullName, token, since, fetchImpl)].slice(0, RELEASES_READ)
82
+ const changes = await repositoryChanges(fullName, token, tagged, kept, fetchImpl)
83
+ return tagged.filter(sha => { const read = changes.get(sha); return read ? releaseChanges(read) : false }).toSorted()
84
+ }
@@ -0,0 +1,209 @@
1
+ // A repository's GitHub Actions in a risk scan (repository-risk.ts): its
2
+ // workflows and its own actions scanned (risk scanWorkflow), and the
3
+ // references they make resolved (risk resolveAction). Of a public
4
+ // repository, a reference resolved and what GitHub answers are kept for
5
+ // every scan, as many repositories use the same few actions.
6
+ import { type ActionsSource, type ResolvedAction, type ScanOptions, type WorkflowScan, dockerfileFindings, keptSource, ownFilesOf, resolveAction,
7
+ scanWorkflow, usesOfKey } from '../risk/index.js'
8
+ import type { OpenedRepository } from './repository-risk-read.ts'
9
+ import type { Lru } from './lru.ts'
10
+ import type { KeptStore } from './npm-kept.ts'
11
+ import { type Queue, queue } from './queue.ts'
12
+ import { RISK_KEPT_MS, type RiskFailure, failureOf } from './repository-risk-assess.ts'
13
+
14
+ export const WORKFLOWS_DIR = '.github/workflows'
15
+ // How many files of a repository's Actions are read, and how large.
16
+ const MAX_FILES = 200
17
+ const MAX_FILE_CHARS = 1024 * 1024
18
+ // How many references a scan resolves, how deep it follows what they name,
19
+ // and how many at once.
20
+ const MAX_ACTIONS = 1000
21
+ const MAX_DEPTH = 4
22
+ const RESOLVERS = 4
23
+
24
+ // A file of a repository's Actions: its text, or why it couldn't be had.
25
+ export type ActionsFile = { path: string; kind: 'workflow' | 'action' | 'dockerfile' } & ({ text: string } | RiskFailure)
26
+ export type WorkflowsRead = { files: ActionsFile[] } | RiskFailure
27
+
28
+ // What a scan tells of them: the workflows' paths, each file's scan, and
29
+ // each reference resolved, or why not.
30
+ export type WorkflowsLine = { type: 'workflows'; repo: number } & ({ paths: string[] } | RiskFailure)
31
+ export type WorkflowLine = { type: 'workflow'; repo: number; path: string; kind: ActionsFile['kind'] } & (WorkflowScan | RiskFailure)
32
+ export type ActionLine = { type: 'action' } & (ResolvedAction | { key: string } & RiskFailure)
33
+
34
+ // A reference to resolve, by the owner of the workflow naming it.
35
+ export interface ActionRef { key: string; owner: string | null; depth: number }
36
+
37
+ // What reading a repository's Actions asks of it.
38
+ export type ActionsReading = Required<Pick<OpenedRepository, 'workflows' | 'file'>>
39
+
40
+ // A file's text, or why not: one GitHub hasn't, or larger than read.
41
+ async function readFile(reading: ActionsReading, path: string): Promise<{ text: string } | RiskFailure> {
42
+ try {
43
+ const text = await reading.file(path)
44
+ return text.length > MAX_FILE_CHARS ? { error: 'workflow-too-large' } : { text }
45
+ } catch (error) { return failureOf(error) }
46
+ }
47
+
48
+ // Whether a read holds what any read of its commit would: no file that
49
+ // couldn't be had for a while, as GitHub's limits are.
50
+ export const lastingRead = (read: WorkflowsRead) => !('error' in read) && read.files.every(file => 'text' in file || file.error === 'workflow-too-large' || file.error === 'github-not-found')
51
+
52
+ // An action's directory, by its metadata's path.
53
+ const actionDir = (path: string) => path.replace(/\/?action\.ya?ml$/u, '')
54
+ const inRepository = (path: string) => !path.split('/').includes('..')
55
+ const notFound = (read: { text: string } | RiskFailure) => 'error' in read && read.error === 'github-not-found'
56
+
57
+ // A repository's workflows, the actions of its own they use in turn (`./`
58
+ // as the workspace has it, the repository checked out first) and those's
59
+ // Dockerfiles, MAX_FILES at most; why not, where they couldn't be listed.
60
+ export async function readActions(reading: ActionsReading): Promise<WorkflowsRead> {
61
+ let listed
62
+ try { listed = await reading.workflows() } catch (error) { return failureOf(error, 'listing its workflows') }
63
+ const files: ActionsFile[] = await Promise.all(listed.slice(0, MAX_FILES).map(async path => ({ path, kind: 'workflow' as const, ...await readFile(reading, path) })))
64
+ const seen = new Set<string>()
65
+ for (let at = 0; at < files.length; at++) {
66
+ const file = files[at]!
67
+ if (!('text' in file) || file.kind === 'dockerfile') continue
68
+ const { actions, dockerfile } = ownFilesOf(file.kind, file.text)
69
+ const path = dockerfile && [actionDir(file.path), dockerfile].filter(Boolean).join('/')
70
+ if (path && inRepository(path) && files.length < MAX_FILES) files.push({ path, kind: 'dockerfile', ...await readFile(reading, path) })
71
+ for (const dir of actions) {
72
+ if (files.length >= MAX_FILES || seen.has(dir) || !inRepository(dir) || !seen.add(dir)) continue
73
+ const named = dir === '' ? 'action' : `${dir}/action`, yml = await readFile(reading, `${named}.yml`)
74
+ const yaml = notFound(yml) ? await readFile(reading, `${named}.yaml`) : null
75
+ files.push(yaml && !notFound(yaml) ? { path: `${named}.yaml`, kind: 'action', ...yaml } : { path: `${named}.yml`, kind: 'action', ...yml })
76
+ }
77
+ }
78
+ return { files }
79
+ }
80
+
81
+ // A lockfile's packages, by name and by `name@version`, and those only
82
+ // devDependencies need.
83
+ export function lockedNames(path: string, packages: readonly { name: string; version: string | null; dev?: true }[]) {
84
+ const dev = new Map<string, boolean>()
85
+ for (const { name, version, dev: only } of packages) for (const key of version ? [name, `${name}@${version}`] : [name]) dev.set(key, (dev.get(key) ?? true) && only === true)
86
+ return { path, names: new Set(dev.keys()), dev: new Set([...dev].flatMap(([name, only]) => only ? [name] : [])) }
87
+ }
88
+
89
+ // The lines a repository's Actions read are told by, and the references
90
+ // they make to resolve. `lockfiles`, its lockfiles' packages' names by path.
91
+ export function actionsLines(repo: number, owner: string, read: WorkflowsRead, lockfiles: NonNullable<ScanOptions['lockfiles']>) {
92
+ if ('error' in read) return { lines: [{ type: 'workflows', repo, ...read } as WorkflowsLine], refs: [] as ActionRef[] }
93
+ const lines: (WorkflowsLine | WorkflowLine)[] = [{ type: 'workflows', repo, paths: read.files.filter(file => file.kind === 'workflow').map(file => file.path) }]
94
+ const refs = new Map<string, ActionRef>()
95
+ const actions = new Map(read.files.flatMap(file => file.kind === 'action' && 'text' in file ? [[actionDir(file.path), file.text] as const] : []))
96
+ for (const file of read.files) {
97
+ const scan = 'error' in file ? file : file.kind === 'dockerfile' ? { uses: [], findings: dockerfileFindings(file.text) } : scanWorkflow(file.kind, file.text, { owner, lockfiles, actions })
98
+ lines.push({ type: 'workflow', repo, path: file.path, kind: file.kind, ...scan })
99
+ if ('uses' in scan) for (const uses of scan.uses) if (uses.kind === 'action' || uses.kind === 'workflow') refs.set(uses.key!, { key: uses.key!, owner, depth: 0 })
100
+ }
101
+ return { lines, refs: [...refs.values()] }
102
+ }
103
+
104
+ export type ActionAnswers = Lru<{ at: number; answer: unknown }>
105
+ // An answer's weight: an entry of a list, a kilobyte of a file.
106
+ export const answerWeight = ({ answer }: { at: number; answer: unknown }) => Array.isArray(answer) ? answer.length + 1
107
+ : typeof answer === 'string' ? Math.ceil(answer.length / 1024) + 1 : ((answer as { branches?: unknown[] } | null)?.branches?.length ?? 0) + 1
108
+
109
+ const isCommit = (text: string) => /^[\da-f]{40}$/u.test(text)
110
+
111
+ // `source`, what it answers of a public repository kept for every scan, the
112
+ // same whoever asks: of a commit while kept (but none, as one can be pushed
113
+ // later), the rest for RISK_KEPT_MS. A private one's, or one missing, never.
114
+ export function sharedSource(source: ActionsSource, answers: ActionAnswers): ActionsSource {
115
+ const keyOf = (what: string, name: string, ...more: string[]) => [what, name.toLowerCase(), ...more].join('\0')
116
+ const kept = (key: string, lasting = false) => {
117
+ const at = answers.get(key)
118
+ return at && ((lasting && at.answer !== null) || Date.now() - at.at < RISK_KEPT_MS) ? at : undefined
119
+ }
120
+ async function shared<T>(name: string, key: string, lasting: boolean, ask: () => Promise<T>): Promise<T> {
121
+ if (!kept(keyOf('repo', name))) return ask()
122
+ const at = kept(key, lasting)
123
+ if (at) return at.answer as T
124
+ const answer = await ask()
125
+ answers.set(key, { at: Date.now(), answer })
126
+ return answer
127
+ }
128
+ return {
129
+ async repo(name) {
130
+ const at = kept(keyOf('repo', name))
131
+ if (at) return at.answer as Awaited<ReturnType<ActionsSource['repo']>>
132
+ const info = await source.repo(name)
133
+ if (info?.private === false) for (const each of [name, info.fullName]) answers.set(keyOf('repo', each), { at: Date.now(), answer: info })
134
+ return info
135
+ },
136
+ tags: name => shared(name, keyOf('tags', name), false, () => source.tags(name)),
137
+ tag: (name, tag) => shared(name, keyOf('tag', name, tag), false, () => source.tag(name, tag)),
138
+ branch: (name, branch) => shared(name, keyOf('branch', name, branch), false, () => source.branch(name, branch)),
139
+ branches: name => shared(name, keyOf('branches', name), false, () => source.branches(name)),
140
+ contains: (name, base, sha) => shared(name, keyOf('contains', name, base, sha), isCommit(base), () => source.contains(name, base, sha)),
141
+ file: (name, path, commit) => shared(name, keyOf('file', name, path, commit), isCommit(commit), () => source.file(name, path, commit)),
142
+ }
143
+ }
144
+
145
+ export interface ActionsResolving {
146
+ source: ActionsSource
147
+ signal: AbortSignal
148
+ tell(line: ActionLine): void
149
+ memory: Lru<{ at: number; line: ActionLine }>
150
+ answers: ActionAnswers
151
+ store?: KeptStore<ActionLine> | undefined
152
+ }
153
+
154
+ // A reference's line: kept, else resolved, and kept where it is a public
155
+ // repository's; why not, where it couldn't be.
156
+ async function resolved(resolving: ActionsResolving, source: ActionsSource, { key, owner }: ActionRef): Promise<ActionLine | null> {
157
+ const kept = resolving.memory.get(key) ?? await resolving.store?.read(key).then(row => row && { at: row.at, line: row.value })
158
+ if (kept && Date.now() - kept.at < RISK_KEPT_MS) { resolving.memory.set(key, kept); return kept.line }
159
+ const uses = usesOfKey(key)
160
+ if (!uses) return { type: 'action', key, error: 'invalid-uses' }
161
+ let found: ResolvedAction
162
+ try { found = await resolveAction(uses, source, { owner }) } catch (error) { return resolving.signal.aborted ? null : { type: 'action', key, ...failureOf(error) } }
163
+ const line: ActionLine = { type: 'action', ...found }
164
+ if (found.repository?.private === false) {
165
+ const at = Date.now()
166
+ resolving.memory.set(key, { at, line })
167
+ await resolving.store?.write(key, line, at)
168
+ }
169
+ return line
170
+ }
171
+
172
+ // Whether a reference is named by a workflow of its repository's owner.
173
+ const ownOwners = ({ key, owner }: ActionRef) => key.split('/')[0] === owner?.toLowerCase()
174
+
175
+ // Resolves the references added, and those they name in turn, each once a
176
+ // scan, RESOLVERS at once, each told as it is; once more where another
177
+ // owner's workflow names one its own owner's did, as a repository missing is
178
+ // red then, or one is met nearer, its own followed as far as they then go. `close` settles once all are told, or the scan stops.
179
+ export function resolvingActions(resolving: ActionsResolving) {
180
+ const source = keptSource(sharedSource(resolving.source, resolving.answers))
181
+ const pending: Queue<ActionRef> = queue(resolving.signal)
182
+ const latest = new Map<string, ActionRef>()
183
+ let closed = false, waiting = 0
184
+ const settle = () => { if (closed && waiting === 0) pending.close() }
185
+ const add = (refs: ActionRef[]) => {
186
+ for (const ref of refs) {
187
+ const first = latest.get(ref.key)
188
+ if (ref.depth > MAX_DEPTH || (first ? (!ownOwners(first) || ownOwners(ref)) && first.depth <= ref.depth : latest.size >= MAX_ACTIONS)) continue
189
+ const kept = first ? { ...ref, owner: ownOwners(first) ? ref.owner : first.owner, depth: Math.min(ref.depth, first.depth) } : ref
190
+ latest.set(ref.key, kept)
191
+ waiting++
192
+ pending.add(kept)
193
+ }
194
+ }
195
+ const work = async () => {
196
+ for (let ref = await pending.take(); ref !== undefined; ref = await pending.take()) {
197
+ try {
198
+ const line = await resolved(resolving, source, ref)
199
+ if (line && latest.get(ref.key) === ref) {
200
+ resolving.tell(line)
201
+ const owner = 'repository' in line ? (line.repository?.fullName ?? line.repo).split('/')[0]! : ref.owner
202
+ if ('uses' in line) add(line.uses.map(key => ({ key, owner, depth: ref.depth + 1 })))
203
+ }
204
+ } finally { waiting--; settle() }
205
+ }
206
+ }
207
+ const workers = Array.from({ length: RESOLVERS }, work)
208
+ return { add, async close() { closed = true; settle(); await Promise.all(workers) } }
209
+ }