@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
@@ -14,6 +14,7 @@ import { Buffer } from 'node:buffer'
14
14
  import { createHash } from 'node:crypto'
15
15
  import { rm } from 'node:fs/promises'
16
16
  import { resolve } from 'node:path'
17
+ import { ArchiveError, type Entry, unpack } from '@preventive/archive/tar.js'
17
18
  import { isExactVersion } from '@preventive/upstream/semver.js'
18
19
  import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
19
20
  import type { Role } from '../common/managed/roles.ts'
@@ -33,6 +34,9 @@ const MAX_DOCUMENT_BYTES = 4 * PACKUMENT_BYTES
33
34
  // stream holding them is bounded in npm-loads.ts.
34
35
  export const MAX_NPM_PACKAGE_BYTES = 64 * 1024 * 1024
35
36
  export const MAX_NPM_PACKAGE_FILES = 20_000
37
+ // The entries its tar stream may hold, read before any is counted as a file:
38
+ // a directory's too, as a GitHub tree's tarball has one for each, npm's none.
39
+ export const MAX_NPM_TAR_ENTRIES = 2 * MAX_NPM_PACKAGE_FILES
36
40
  // What the files may take as the viewer's JSON. Escaping grows a text up to
37
41
  // six times (a control character becomes `\u0001`), so it is counted before
38
42
  // anything is serialized.
@@ -277,73 +281,29 @@ export const npmPublishTime = (name: string, version: string): string | null =>
277
281
 
278
282
  export interface NpmPackageFile { path: string; bytes: Uint8Array }
279
283
 
280
- const textOf = (bytes: Uint8Array, start: number, length: number) => {
281
- const field = bytes.subarray(start, start + length)
282
- const end = field.indexOf(0)
283
- return Buffer.from(end === -1 ? field : field.subarray(0, end)).toString('utf8')
284
- }
285
-
286
- function sizeOf(bytes: Uint8Array, offset: number): number {
287
- // GNU base-256 for sizes past the 11 octal digits.
288
- if (bytes[offset]! & 0x80) {
289
- let size = bytes[offset]! & 0x7f
290
- for (let i = 1; i < 12; i++) size = size * 256 + bytes[offset + i]!
291
- return size
284
+ // The regular files of a tar stream, by package path: npm drops the first
285
+ // component (`package/`, or whatever the archive used), and a file at the top
286
+ // is not extracted. The stream is read by @preventive/archive, which refuses
287
+ // one malformed, a name or a symlink that would climb out of it -- a symlink
288
+ // past `symlinksAbove` levels above it, none unless given -- and a name
289
+ // repeated as another entry, and gives up past MAX_NPM_TAR_ENTRIES before
290
+ // reading on; a file under another top directory at the same path replaces
291
+ // an earlier one, as extraction would. Directories, links and devices hold no
292
+ // file content and are skipped.
293
+ export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0 }: { symlinksAbove?: number } = {}): NpmPackageFile[] {
294
+ let entries: Entry[]
295
+ try { entries = unpack(tar, { maxEntries: MAX_NPM_TAR_ENTRIES, symlinksAbove }) }
296
+ catch (err) {
297
+ if (err instanceof ArchiveError) throw err.limited ? new NpmPackageError(413, 'package-too-large') : new NpmPackageError(422, 'bad-tarball')
298
+ throw err
292
299
  }
293
- const text = textOf(bytes, offset, 12).trim()
294
- return /^[0-7]+$/u.test(text) ? Number.parseInt(text, 8) : Number.NaN
295
- }
296
-
297
- // The path a package's file is installed at: npm drops the first component
298
- // (`package/`, or whatever the archive used), and a path that would escape the
299
- // package, or names nothing, is not extracted.
300
- function packagePath(raw: string): string | null {
301
- const parts = raw.split('/').slice(1).filter(part => part !== '' && part !== '.')
302
- return parts.length === 0 || parts.includes('..') ? null : parts.join('/')
303
- }
304
-
305
- // A pax header's path. Each record is `<length> <key>=<value>\n`, its length
306
- // in bytes, so records are walked as bytes and only their values decoded.
307
- function paxPath(data: Uint8Array): string | null {
308
- let path = null
309
- let at = 0
310
- while (at < data.length) {
311
- const space = data.indexOf(0x20, at)
312
- const length = Number.parseInt(Buffer.from(data.subarray(at, space)).toString('latin1'), 10)
313
- if (space === -1 || !Number.isSafeInteger(length) || length <= 0) break
314
- const record = Buffer.from(data.subarray(space + 1, at + length - 1)).toString('utf8')
315
- if (record.startsWith('path=')) path = record.slice(5)
316
- at += length
317
- }
318
- return path
319
- }
320
-
321
- // The regular files of a tar stream, by package path, a later entry
322
- // replacing an earlier one as extraction would. Directories, links and
323
- // devices hold no file content and are skipped.
324
- export function readNpmTar(tar: Uint8Array): NpmPackageFile[] {
325
300
  const files = new Map<string, Uint8Array>()
326
301
  let total = 0
327
- let longName: string | null = null
328
- let offset = 0
329
- while (offset + 512 <= tar.length) {
330
- const header = tar.subarray(offset, offset + 512)
331
- if (header.every(byte => byte === 0)) break
332
- const size = sizeOf(header, 124)
333
- const type = String.fromCodePoint(header[156]!)
334
- const start = offset + 512
335
- if (!Number.isSafeInteger(size) || size < 0 || start + size > tar.length) throw new NpmPackageError(422, 'bad-tarball')
336
- const data = tar.subarray(start, start + size)
337
- offset = start + Math.ceil(size / 512) * 512
338
- if (type === 'x' || type === 'L') { longName = type === 'x' ? paxPath(data) : textOf(data, 0, data.length); continue }
339
- if (type === 'g' || type === 'K') continue
340
- const prefix = textOf(header, 257, 6) === 'ustar' ? textOf(header, 345, 155) : ''
341
- const name = longName ?? (prefix ? `${prefix}/${textOf(header, 0, 100)}` : textOf(header, 0, 100))
342
- longName = null
343
- if (!['0', '\0', '7'].includes(type)) continue
344
- const path = packagePath(name)
345
- if (path === null) continue
346
- total += size - (files.get(path)?.length ?? 0)
302
+ for (const { name, type, data } of entries) {
303
+ const slash = name.indexOf('/')
304
+ if ((type !== 'file' && type !== 'contiguous-file') || slash === -1) continue
305
+ const path = name.slice(slash + 1)
306
+ total += data.length - (files.get(path)?.length ?? 0)
347
307
  files.set(path, data)
348
308
  if (files.size > MAX_NPM_PACKAGE_FILES || total > MAX_NPM_PACKAGE_BYTES) throw new NpmPackageError(413, 'package-too-large')
349
309
  }
@@ -0,0 +1,161 @@
1
+ // The reviews of dependencies an open pull request's comments make, as the
2
+ // host's parser reads them (reviewed-dependencies.ts `parseComments`): its
3
+ // comments, its reviews' own, whatever their state, and those on lines of
4
+ // its diff, read of GitHub's GraphQL API, as many as MAX_COMMENTS, with
5
+ // whether it is open, kept in this instance KEPT_MS; none of one not open;
6
+ // the parser's answer to the same kept PARSED_KEPT_MS.
7
+ // A review counts only where its user is one of their authors and the app's
8
+ // user that account is (by its numeric id, else its login, its linked
9
+ // accounts' too) publishes reviews to a team counting for the repository
10
+ // (`reviewers`): by that user, as their reviews here are, each once among
11
+ // them (pull-dependency-reviews.ts), as of its time, else their latest
12
+ // comment's. They count for the pull request alone, never stored.
13
+ import { createHash } from 'node:crypto'
14
+ import { ACTOR, type GraphAccount, actorOf } from './github-accounts.ts'
15
+ import { type Lru, lru } from './lru.ts'
16
+ import type { CommentReviews } from './pull-dependency-reviews.ts'
17
+ import type { PublishedReview } from './repository-dependency-reviews.ts'
18
+ import { githubGraphql } from './repository-history.ts'
19
+ import type { CommentReview, ParseCommentsInput, ReviewComment, ReviewVerdict, ReviewedDependencies } from './reviewed-dependencies.ts'
20
+
21
+ const MAX_COMMENTS = 1000
22
+ const KEPT_MS = 60_000
23
+ const PARSED_KEPT_MS = 10 * 60_000
24
+ const VERDICTS = new Set<string>(['accept', 'accept-with-nits', 'request-changes', 'reject'])
25
+
26
+ // A comment as read: when it was written, and last edited, where it was.
27
+ const COMMENT = `id author { ${ACTOR} } body createdAt lastEditedAt url`
28
+ // Its comments and its reviews, each a page on from its cursor, each only
29
+ // while it has more (`readComments`, `readReviews`).
30
+ const QUERY = `query($owner: String!, $name: String!, $number: Int!, $comments: String, $reviews: String, $readComments: Boolean!, $readReviews: Boolean!) {
31
+ repository(owner: $owner, name: $name) { pullRequest(number: $number) { state
32
+ comments(first: 100, after: $comments) @include(if: $readComments) { pageInfo { hasNextPage endCursor } nodes { ${COMMENT} } }
33
+ reviews(first: 50, after: $reviews) @include(if: $readReviews) { pageInfo { hasNextPage endCursor }
34
+ nodes { ${COMMENT} state comments(first: 100) { pageInfo { hasNextPage endCursor } nodes { ${COMMENT} } } } }
35
+ } } }`
36
+ // A review's comments on lines of its diff, a page on from its cursor.
37
+ const REVIEW_COMMENTS = `query($id: ID!, $after: String) { node(id: $id) { ... on PullRequestReview {
38
+ comments(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { ${COMMENT} } } } } }`
39
+
40
+ // A comment as read, and its author's account.
41
+ interface Authored { comment: ReviewComment; account: { login: string | null; id: number | null } }
42
+ type Page = { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: unknown }
43
+ type Node = { id?: unknown; author?: GraphAccount; body?: unknown; state?: unknown; createdAt?: unknown; lastEditedAt?: unknown; url?: unknown; comments?: Page }
44
+
45
+ // A pull request's comments as read, and whether it is open.
46
+ interface PullComments { open: boolean; read: Authored[] }
47
+ const comments: Lru<{ at: number } & PullComments> = lru(200)
48
+ const answers: Lru<{ at: number; reviews: CommentReview[] }> = lru(2000)
49
+
50
+ function authored(node: Node, kind: ReviewComment['kind']): Authored[] {
51
+ if (typeof node?.id !== 'string' || typeof node.body !== 'string' || typeof node.createdAt !== 'string') return []
52
+ const account = actorOf(node.author)
53
+ return [{ account, comment: { kind, id: node.id, author: account.login, body: node.body, ...typeof node.state === 'string' ? { state: node.state } : {},
54
+ createdAt: node.createdAt, ...typeof node.lastEditedAt === 'string' ? { editedAt: node.lastEditedAt } : {}, url: typeof node.url === 'string' ? node.url : '' } }]
55
+ }
56
+ const nodes = (list: unknown): Node[] => Array.isArray(list) ? list as Node[] : []
57
+ // The cursor a page ends at, where more follow it.
58
+ const next = (page: Page | undefined) => page?.pageInfo?.hasNextPage === true && typeof page.pageInfo.endCursor === 'string' ? page.pageInfo.endCursor : null
59
+
60
+ // Review `review`'s comments on lines of its diff: the page read with it,
61
+ // then those after it read of GitHub, till as many as `room` are.
62
+ async function inlineComments(review: Node, room: number, token: string, fetchImpl: typeof fetch): Promise<Authored[]> {
63
+ const read = nodes(review.comments?.nodes).flatMap(inline => authored(inline, 'review-comment'))
64
+ for (let after = typeof review.id === 'string' ? next(review.comments) : null; after !== null && read.length < room;) {
65
+ const data = await githubGraphql(REVIEW_COMMENTS, { id: review.id, after }, token, fetchImpl) as { node?: { comments?: Page } | null } | null
66
+ read.push(...nodes(data?.node?.comments?.nodes).flatMap(inline => authored(inline, 'review-comment')))
67
+ after = next(data?.node?.comments)
68
+ }
69
+ return read
70
+ }
71
+
72
+ // Pull request `number`'s comments, its reviews' and those on its diff, each
73
+ // review's all of them, and whether it is open, as kept, else read of GitHub
74
+ // a page at a time.
75
+ export async function pullComments(fullName: string, token: string, number: number, fetchImpl: typeof fetch = globalThis.fetch): Promise<PullComments> {
76
+ const key = `${fullName}\0${number}`
77
+ const known = comments.get(key)
78
+ if (known && Date.now() - known.at < KEPT_MS) return known
79
+ const [owner, name] = fullName.split('/') as [string, string]
80
+ const read: Authored[] = []
81
+ let open: boolean | null = null
82
+ // Each connection read on from its cursor till it has no more, the other
83
+ // read on without it, never again from its first page.
84
+ let cursors: { comments: string | null; reviews: string | null } = { comments: null, reviews: null }
85
+ let reading = { comments: true, reviews: true }
86
+ while ((reading.comments || reading.reviews) && read.length < MAX_COMMENTS) {
87
+ const data = await githubGraphql(QUERY, { owner, name, number, ...cursors, readComments: reading.comments, readReviews: reading.reviews }, token, fetchImpl) as
88
+ { repository?: { pullRequest?: { state?: unknown; comments?: Page; reviews?: Page } | null } | null } | null
89
+ const pull = data?.repository?.pullRequest
90
+ if (!pull) break
91
+ open ??= pull.state === 'OPEN'
92
+ for (const node of nodes(pull.comments?.nodes)) read.push(...authored(node, 'comment'))
93
+ for (const node of nodes(pull.reviews?.nodes)) {
94
+ read.push(...authored(node, 'review'))
95
+ read.push(...await inlineComments(node, MAX_COMMENTS - read.length, token, fetchImpl))
96
+ }
97
+ cursors = { comments: reading.comments ? next(pull.comments) : null, reviews: reading.reviews ? next(pull.reviews) : null }
98
+ reading = { comments: cursors.comments !== null, reviews: cursors.reviews !== null }
99
+ }
100
+ const kept = { open: open === true, read: read.slice(0, MAX_COMMENTS) }
101
+ comments.set(key, { at: Date.now(), ...kept })
102
+ return kept
103
+ }
104
+
105
+ // The parser's reviews of `input`, as kept for the same, else asked.
106
+ async function parsed(parse: NonNullable<ReviewedDependencies['parseComments']>, input: ParseCommentsInput): Promise<CommentReview[]> {
107
+ const key = createHash('sha256').update(JSON.stringify(input)).digest('base64url')
108
+ const known = answers.get(key)
109
+ if (known && Date.now() - known.at < PARSED_KEPT_MS) return known.reviews
110
+ const reviews = await parse(structuredClone(input))
111
+ if (!Array.isArray(reviews)) throw new TypeError('parseComments answered with no list')
112
+ answers.set(key, { at: Date.now(), reviews })
113
+ return reviews
114
+ }
115
+
116
+ const when = (time: unknown) => typeof time === 'number' ? time : typeof time === 'string' ? Date.parse(time) : Number.NaN
117
+ // The reviews `answer` makes that count, by the users `reviewers` names,
118
+ // each dependency's of each hash its integrity names, as of the time it
119
+ // tells, else its user's latest comment's, as last written or edited.
120
+ function counted(answer: CommentReview[], read: Authored[], reviewers: Map<string, string>): PublishedReview[] {
121
+ const latest = new Map<string, number>()
122
+ for (const { comment } of read) {
123
+ const login = comment.author?.toLowerCase()
124
+ if (login) latest.set(login, Math.max(latest.get(login) ?? 0, when(comment.createdAt) || 0, when(comment.editedAt) || 0))
125
+ }
126
+ return answer.flatMap(review => {
127
+ const login = typeof review?.user === 'string' ? review.user.toLowerCase() : ''
128
+ const reviewer = reviewers.get(login)
129
+ if (!reviewer || !latest.has(login) || !Array.isArray(review.dependencies)) return []
130
+ const time = Number.isFinite(when(review.time)) ? when(review.time) : latest.get(login)!
131
+ return review.dependencies.flatMap(dependency => {
132
+ const { name, version, integrity, patchHash, status } = dependency ?? {}
133
+ if (typeof name !== 'string' || typeof version !== 'string' || typeof integrity !== 'string' || !VERDICTS.has(status)) return []
134
+ return integrity.split(/\s+/u).filter(Boolean).map(hash => ({ package: name, version, integrity: hash, ...typeof patchHash === 'string' && patchHash ? { patchHash } : {},
135
+ basePackage: null, baseVersion: null, reviewer, status: status as ReviewVerdict, full: true, time }))
136
+ })
137
+ })
138
+ }
139
+
140
+ // The reviews pull request `number`'s comments make, as the host's parser
141
+ // reads them, of the versions still wanting one, none where it isn't open
142
+ // (the parser then not asked); `reviewers` the app's users
143
+ // the accounts are, by their logins in lower case, of those whose reviews
144
+ // count for the repository.
145
+ export function commentReviews({ fullName, number, token, parse, reviewers, fetchImpl = globalThis.fetch }: {
146
+ fullName: string; number: number; token: string; parse: NonNullable<ReviewedDependencies['parseComments']>
147
+ reviewers: (accounts: { login: string; githubId: number | null }[]) => Promise<Map<string, string>>; fetchImpl?: typeof fetch
148
+ }): CommentReviews {
149
+ return async wanting => {
150
+ try {
151
+ const { open, read } = await pullComments(fullName, token, number, fetchImpl)
152
+ if (!open) return { reviews: [], failed: false }
153
+ const accounts = new Map(read.flatMap(({ account }) => account.login ? [[account.login.toLowerCase(), { login: account.login, githubId: account.id }]] : []))
154
+ const answer = await parsed(parse, { repository: fullName, number, comments: read.map(({ comment }) => comment), dependencies: wanting })
155
+ return { reviews: counted(answer, read, await reviewers([...accounts.values()])), failed: false }
156
+ } catch (error) {
157
+ console.warn('reviewed dependencies: comments of', `${fullName}#${number}`, 'not read:', error instanceof Error ? error.message : error)
158
+ return { reviews: [], failed: true }
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,280 @@
1
+ // The package versions a pull request's lockfiles lock that they didn't
2
+ // before it, as the code viewer's Pull requests tab shows them on an open
3
+ // one's tile and its popup on any (GET
4
+ // /api/admin/repositories/pull-dependencies): read only where it changes a
5
+ // lockfile of npm's ecosystem that @preventive/lockfile reads
6
+ // (package-lock.json and npm-shrinkwrap.json of `lockfileVersion: 3`,
7
+ // pnpm-lock.yaml of pnpm 9 to 12 and Yarn 1's yarn.lock, locked-packages.ts),
8
+ // and Bun's, which it doesn't (bun.lock and bun.lockb, its directory told
9
+ // `lockfile-unsupported`); none under a node_modules nor a test's fixtures
10
+ // (isTestFixture), which no install takes. Its files are GitHub's comparison of its base with its head
11
+ // (GET /repos/:owner/:repo/compare/:base...:head), which names where its
12
+ // branch left its base, its merge base, as GitHub's own Files changed
13
+ // compares from it, and lists as many as a commit's files are read
14
+ // (repository-changes.ts MAX_FILES, 300, the most it lists); one changing as
15
+ // many, from its own list of them, a page at a time, as many as PULL_FILES.
16
+ // Each directory's lockfiles it changes are read at its merge base and at its
17
+ // head, a lockfile replaced by another, Yarn's by pnpm's say, read as the
18
+ // one it replaced; a version new to a directory is one its lockfiles lock at
19
+ // the head that none of them locked at the merge base, from where it is
20
+ // installed: a package new to it, or a version of one they locked others of,
21
+ // a bump's, told with those (`was`). What a pair of commits holds never
22
+ // changes, so what is told of one is kept by them (`kept`), and each
23
+ // lockfile read is kept in this instance's memory by its commit
24
+ // (`lockfileReads`): the pull requests branching off the same commit, as a
25
+ // bot's do, read their base's once.
26
+ import { createClient } from '@preventive/upstream/github.js'
27
+ import { GithubApiError, githubJson } from './github-app.ts'
28
+ import { upstreamGithub } from './github-errors.ts'
29
+ import { compareVersions, isExactVersion } from '@preventive/upstream/semver.js'
30
+ import { type LockedPackage, isTestFixture, lockedPackages } from './locked-packages.ts'
31
+ import { type Lru, lru } from './lru.ts'
32
+ import { type ChangedFile, commitChanges } from './repository-changes.ts'
33
+
34
+ // The files of a pull request read from its own list past those compared,
35
+ // FILES_PAGE a page, as many as GitHub lists (3,000).
36
+ const FILES_PAGE = 100
37
+ export const PULL_FILES = 3000
38
+ // The directories whose lockfiles are read, those past them left out
39
+ // (`cut`), and the packages told new of each, those past them counted.
40
+ export const MAX_DIRECTORIES = 20
41
+ export const MAX_ADDED = 200
42
+ // The versions a directory's lockfiles lock at the head kept with what they
43
+ // add, as many as MAX_LOCKED, those past them counted, for those not new to
44
+ // be reviewed as well (pull-dependency-reviews.ts).
45
+ export const MAX_LOCKED = 20_000
46
+ // Lockfiles read at once.
47
+ const READ_PARALLEL = 4
48
+ // The rules what is told was told by, kept with it (http.ts
49
+ // pullDependencyKept): one told by others is told again.
50
+ export const PULL_DEPENDENCIES_READ = 3
51
+ const SHA = /^[a-f\d]{40}$/u
52
+
53
+ type Format = 'npm' | 'pnpm' | 'yarn' | 'bun'
54
+
55
+ // The format of the lockfile of npm's ecosystem at `path`, as
56
+ // findRepoLockfiles names it (with pnpm's of a branch,
57
+ // pnpm-lock.<branch>.yaml, and of before pnpm 3, shrinkwrap.yaml, which its
58
+ // reader refuses); null for another file, and for one under a node_modules or
59
+ // of a test's fixtures. Bun's alone isn't read.
60
+ export function lockfileFormat(path: string): Format | null {
61
+ const dirs = path.split('/')
62
+ const name = dirs.pop() ?? ''
63
+ if (dirs.includes('node_modules') || isTestFixture(path)) return null
64
+ if (name === 'package-lock.json' || name === 'npm-shrinkwrap.json') return 'npm'
65
+ if (/^pnpm-lock(?:\..+)?\.yaml$/u.test(name) || name === 'shrinkwrap.yaml') return 'pnpm'
66
+ if (name === 'bun.lock' || name === 'bun.lockb') return 'bun'
67
+ return name === 'yarn.lock' ? 'yarn' : null
68
+ }
69
+
70
+ // Whether a pull request changing `paths` changes a lockfile read here: null
71
+ // where none of them is one but they are fewer than it changes
72
+ // (`changed`), which it may yet.
73
+ export function touchesLockfile(paths: string[], changed: number): boolean | null {
74
+ if (paths.some(path => lockfileFormat(path) !== null)) return true
75
+ return changed > paths.length ? null : false
76
+ }
77
+
78
+ // A package version new to a directory, as its lockfile locks it, with its
79
+ // integrity, which its reviews are held to; of a package its lockfiles locked
80
+ // before, the versions of it they did (`was`, the highest first), none of one
81
+ // new to it.
82
+ export type AddedPackage = LockedPackage & { was?: string[] }
83
+ // A directory's lockfiles the pull request changes (`paths`, those it adds,
84
+ // modifies, removes or renames, by either name): the package versions they
85
+ // lock new to it, by name and version (as many as MAX_LOCKED, told as many
86
+ // as MAX_ADDED, `more` past them), and all they lock (`locked`, as many as MAX_LOCKED, how many more
87
+ // `moreLocked`); or why they couldn't be told, a lockfile's reader refusing one
88
+ // (`lockfile-unreadable`, with what it said), one of a format not read
89
+ // (`lockfile-unsupported`, Bun's) or GitHub having none at a commit compared.
90
+ export type DirectoryDependencies = { paths: string[] } & ({ added: AddedPackage[]; more: number; locked: LockedPackage[]; moreLocked: number }
91
+ | { error: string; detail?: string })
92
+ // What a pull request's lockfiles add: the commits compared (`base`, its
93
+ // merge base, and `head`); each directory's, of those it changes a lockfile
94
+ // in; and `cut` where it changes more files than GitHub lists, or lockfiles
95
+ // in more directories than are read, with, where only those cut it and they
96
+ // are few enough, the paths of those directories' lockfiles (`omitted`), for
97
+ // a review holding to none of them to fail in nothing.
98
+ export interface PullDependencies { base: string; head: string; lockfiles: DirectoryDependencies[]; cut: boolean; omitted?: string[] }
99
+ const MAX_OMITTED = 1000
100
+
101
+ // What is told of a pull request, by the commits it is of, `${base}...${head}`.
102
+ export interface PullDependencyStore {
103
+ read(key: string): Promise<PullDependencies | null>
104
+ write(key: string, value: PullDependencies): Promise<void>
105
+ }
106
+
107
+ // A lockfile's packages, or why its reader refused it, by its repository,
108
+ // commit and path, read in this instance; by the packages each holds, as the
109
+ // scan's are (repository-risk.ts riskCaches).
110
+ type LockfileRead = { packages: LockedPackage[] } | { error: string; detail?: string }
111
+ const lockfileReads: Lru<LockfileRead> = lru(200_000, read => 'packages' in read ? read.packages.length + 1 : 1)
112
+
113
+ const api = (fullName: string) => `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
114
+
115
+ // The pull request's base's and head's commits, as GitHub has them now: its
116
+ // base's moving on as it does, its head's with each push, both kept once it
117
+ // is merged or closed.
118
+ async function pullCommits(fullName: string, token: string, number: number, fetchImpl: typeof fetch) {
119
+ const pull = await githubJson(`${api(fullName)}/pulls/${number}`, token, fetchImpl) as
120
+ { number?: unknown; base?: { sha?: unknown; repo?: { full_name?: unknown } | null }; head?: { sha?: unknown } } | null
121
+ const base = pull?.base?.sha, head = pull?.head?.sha, repo = pull?.base?.repo?.full_name
122
+ if (pull?.number !== number || typeof base !== 'string' || !SHA.test(base) || typeof head !== 'string' || !SHA.test(head)
123
+ || typeof repo !== 'string' || repo.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(502, 'github-malformed')
124
+ return { base, head }
125
+ }
126
+
127
+ // The pull request's merge base and the files it changes from it: as GitHub
128
+ // compares its base with its head, else, where that lists as many as it lists
129
+ // at most, from the pull request's own list, a page at a time; `cut` where
130
+ // that lists as many as it lists at most, too.
131
+ async function pullFiles(fullName: string, token: string, number: number, base: string, head: string, fetchImpl: typeof fetch) {
132
+ const compared = await githubJson(`${api(fullName)}/compare/${base}...${head}?per_page=1`, token, fetchImpl) as { merge_base_commit?: { sha?: unknown } } | null
133
+ const mergeBase = compared?.merge_base_commit?.sha
134
+ if (typeof mergeBase !== 'string' || !SHA.test(mergeBase)) throw new GithubApiError(502, 'github-malformed')
135
+ const { files, more } = commitChanges(compared)
136
+ if (!more) return { mergeBase, files, cut: false }
137
+ const listed: ChangedFile[] = []
138
+ for (let page = 1; listed.length < PULL_FILES; page++) {
139
+ const data = await githubJson(`${api(fullName)}/pulls/${number}/files?per_page=${FILES_PAGE}&page=${page}`, token, fetchImpl)
140
+ if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
141
+ listed.push(...commitChanges({ files: data }).files)
142
+ if (data.length < FILES_PAGE) return { mergeBase, files: listed, cut: false }
143
+ }
144
+ return { mergeBase, files: listed, cut: true }
145
+ }
146
+
147
+ // The lockfiles read here a pull request changes (`files`), by directory:
148
+ // each as it was (`before`, one modified or removed, or a rename's path
149
+ // before) and as it is (`after`, one added or modified, or a rename's path
150
+ // now), and every path of them (`paths`), as many directories as
151
+ // MAX_DIRECTORIES.
152
+ function lockfilesChanged(files: ChangedFile[]) {
153
+ type Directory = { before: { path: string; format: Format }[]; after: { path: string; format: Format }[]; paths: Set<string> }
154
+ const directories = new Map<string, Directory>()
155
+ const take = (side: 'before' | 'after', path: string | undefined) => {
156
+ const format = path ? lockfileFormat(path) : null
157
+ if (!path || !format) return
158
+ const dir = path.slice(0, Math.max(path.lastIndexOf('/'), 0))
159
+ const directory = directories.get(dir) ?? directories.set(dir, { before: [], after: [], paths: new Set() }).get(dir)!
160
+ directory[side].push({ path, format })
161
+ directory.paths.add(path)
162
+ }
163
+ for (const [path, status, from] of files) {
164
+ take('before', status === 'R' ? from : status === 'A' ? undefined : path)
165
+ take('after', status === 'D' ? undefined : path)
166
+ }
167
+ const all = [...directories.entries()].toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
168
+ return { directories: all.slice(0, MAX_DIRECTORIES).map(([, directory]) => directory), cut: all.length > MAX_DIRECTORIES,
169
+ omitted: all.slice(MAX_DIRECTORIES).flatMap(([, directory]) => [...directory.paths]).toSorted() }
170
+ }
171
+
172
+ // A lockfile's packages at `ref`, as read in this instance, else of GitHub;
173
+ // a lockfile its reader refuses, why. Any failure to read it of GitHub but
174
+ // its having none there is the whole answer's, as it would be every file's.
175
+ export async function readLockfile(fullName: string, token: string, ref: string, { path, format }: { path: string; format: Format }): Promise<LockfileRead> {
176
+ if (format === 'bun') return { error: 'lockfile-unsupported' }
177
+ const key = `${fullName}\0${ref}\0${path}`
178
+ const known = lockfileReads.get(key)
179
+ if (known) return known
180
+ let text: string
181
+ try {
182
+ text = await upstreamGithub(() => createClient({ token, userAgent: 'deepview-triage' }).getRepoFile({ repo: fullName, path, ref }))
183
+ } catch (err) {
184
+ if (err instanceof GithubApiError && err.message === 'github-not-found') return { error: 'github-not-found' }
185
+ throw err
186
+ }
187
+ let read: LockfileRead
188
+ try { read = { packages: lockedPackages(format, text, { review: true }) } } catch (error) {
189
+ read = { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
190
+ }
191
+ lockfileReads.set(key, read)
192
+ return read
193
+ }
194
+
195
+ // Runs `run` for each of `items`, as many at once as `parallel`, each's
196
+ // result in its place.
197
+ export async function eachOf<T, R>(items: T[], parallel: number, run: (item: T) => Promise<R>): Promise<R[]> {
198
+ const results: R[] = []
199
+ let next = 0
200
+ await Promise.all(Array.from({ length: Math.min(parallel, items.length) }, async () => {
201
+ while (next < items.length) {
202
+ const at = next++
203
+ results[at] = await run(items[at]!)
204
+ }
205
+ }))
206
+ return results
207
+ }
208
+
209
+ // Versions by semver, the highest first, any semver can't read after them.
210
+ function byVersion(a: string, b: string) {
211
+ const [exactA, exactB] = [isExactVersion(a), isExactVersion(b)]
212
+ if (exactA && exactB) return compareVersions(b, a)
213
+ if (exactA === exactB) return a < b ? -1 : a > b ? 1 : 0
214
+ return exactA ? -1 : 1
215
+ }
216
+ const byName = (a: AddedPackage, b: AddedPackage) => a.name < b.name ? -1 : a.name > b.name ? 1 : -byVersion(a.version ?? '', b.version ?? '')
217
+ // A package as it is installed: its name and version, where from, and its
218
+ // patch, a patched version's new where its patch is; and as it is locked,
219
+ // with what its reviews are held to too.
220
+ export const lockedAs = (pkg: LockedPackage) => `${pkg.source}\0${pkg.name}@${pkg.version ?? ''}\0${pkg.from ?? ''}\0${pkg.patchHash ?? ''}`
221
+ export const lockedWith = (pkg: LockedPackage) => `${lockedAs(pkg)}\0${pkg.integrity ?? ''}\0${pkg.shasum ?? ''}\0${pkg.lockfile ?? ''}`
222
+
223
+ // What a directory's lockfiles add, from each read before and after: the
224
+ // package versions after none before has, each once, by name and version,
225
+ // each with the versions of its name before, those past MAX_ADDED counted
226
+ // (`more`), not told but reviewed as they are; and every one after, each
227
+ // integrity it is locked with (`locked`), as many as MAX_LOCKED; or the first
228
+ // failure among them.
229
+ function addedIn(paths: string[], before: LockfileRead[], after: LockfileRead[]): DirectoryDependencies {
230
+ const failed = [...before, ...after].find(read => 'error' in read)
231
+ if (failed && 'error' in failed) return { paths, error: failed.error, ...failed.detail ? { detail: failed.detail } : {} }
232
+ const known = new Set<string>()
233
+ const versions = new Map<string, Set<string>>()
234
+ for (const pkg of before.flatMap(read => 'packages' in read ? read.packages : [])) {
235
+ known.add(lockedAs(pkg))
236
+ const of = versions.get(pkg.name) ?? versions.set(pkg.name, new Set()).get(pkg.name)!
237
+ if (pkg.version) of.add(pkg.version)
238
+ }
239
+ const added = new Map<string, AddedPackage>()
240
+ const locked = new Map<string, LockedPackage>()
241
+ for (const read of after) {
242
+ for (const pkg of 'packages' in read ? read.packages : []) {
243
+ const key = lockedAs(pkg)
244
+ const held = locked.get(lockedWith(pkg))
245
+ if (!held) locked.set(lockedWith(pkg), { ...pkg })
246
+ else if (held.dev && !pkg.dev) delete held.dev
247
+ if (known.has(key)) continue
248
+ const had = added.get(key)
249
+ const was = versions.get(pkg.name)
250
+ if (!had) added.set(key, { ...pkg, ...was ? { was: [...was].toSorted(byVersion) } : {} })
251
+ else if (had.dev && !pkg.dev) delete had.dev
252
+ }
253
+ }
254
+ const all = [...added.values()].toSorted(byName)
255
+ return { paths, added: all.slice(0, MAX_LOCKED), more: Math.max(0, all.length - MAX_ADDED),
256
+ locked: [...locked.values()].slice(0, MAX_LOCKED), moreLocked: Math.max(0, locked.size - MAX_LOCKED) }
257
+ }
258
+
259
+ // What pull request `number` adds to its lockfiles' packages (PullDependencies),
260
+ // kept by its base's and head's commits, else read of GitHub and kept; none
261
+ // where it changes no lockfile read here.
262
+ export async function pullDependencies(fullName: string, token: string, number: number, kept: PullDependencyStore,
263
+ fetchImpl: typeof fetch = globalThis.fetch): Promise<PullDependencies> {
264
+ const { base, head } = await pullCommits(fullName, token, number, fetchImpl)
265
+ const key = `${base}...${head}`
266
+ const held = await kept.read(key)
267
+ if (held) return held
268
+ const { mergeBase, files, cut: filesCut } = await pullFiles(fullName, token, number, base, head, fetchImpl)
269
+ const { directories, cut, omitted } = lockfilesChanged(files)
270
+ const reads = directories.flatMap(({ before, after }) => [...before.map(lockfile => [mergeBase, lockfile] as const), ...after.map(lockfile => [head, lockfile] as const)])
271
+ const read = await eachOf(reads, READ_PARALLEL, ([ref, lockfile]) => readLockfile(fullName, token, ref, lockfile))
272
+ let at = 0
273
+ const lockfiles = directories.map(({ before, after, paths }) => {
274
+ const readBefore = read.slice(at, at += before.length)
275
+ return addedIn([...paths].toSorted(), readBefore, read.slice(at, at += after.length))
276
+ })
277
+ const told = { base: mergeBase, head, lockfiles, cut: filesCut || cut, ...cut && !filesCut && omitted.length <= MAX_OMITTED ? { omitted } : {} }
278
+ await kept.write(key, told)
279
+ return told
280
+ }