@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
@@ -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
  }
@@ -250,18 +250,21 @@ export function stalePrettyRemover(npm: CacheStorage): (signal?: AbortSignal) =>
250
250
  }
251
251
  }
252
252
 
253
+ // A bundle's details, from its bytes in `store`: bundles are read one at a
254
+ // time, as reading one decodes all of it.
255
+ function detailsReader(store: BundleStore) {
256
+ const enqueue = serial()
257
+ return (record: ManagedBundle) => enqueue(async () => {
258
+ const details = await readBundleDetails(record, store)
259
+ if (!details) throw new PrettyError(422, 'bundle-unavailable')
260
+ return details
261
+ })
262
+ }
263
+
253
264
  export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage, db: Pick<ManagedDb, 'getBundle'>, store: BundleStore) {
254
265
  // Copies being made; readers asking for one meanwhile share it.
255
266
  const shared = sharing<Buffer>()
256
- // Bundles are read one at a time: reading one decodes all of it.
257
- const enqueue = serial()
258
- function bundleDetails(record: ManagedBundle) {
259
- return enqueue(async () => {
260
- const details = await readBundleDetails(record, store)
261
- if (!details) throw new PrettyError(422, 'bundle-unavailable')
262
- return details
263
- })
264
- }
267
+ const bundleDetails = detailsReader(store)
265
268
  // A bundle's file pretty-printed from the text `read` reads, and kept in
266
269
  // its cache.
267
270
  function bundleCopy(record: ManagedBundle, request: PrettyRequest, read: () => Promise<string>): Promise<Buffer> {
@@ -341,6 +344,8 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
341
344
  made: async (request, text) => opened(await bundleCopy(record, request, () => Promise.resolve(text))) })
342
345
  return [await sideOf(base, 'base'), await sideOf(other, 'other')]
343
346
  },
347
+ // A bundle's details, read in its turn, as Compare pairs them (comparison.ts).
348
+ bundleDetails,
344
349
  async npmSides(base: NpmVersionDocument, other: NpmVersionDocument): Promise<[ComparedSide, ComparedSide]> {
345
350
  const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc)), packageDirs: null,
346
351
  kept: request => (doc.private ? Promise.resolve(null) : kept(() => npm.open(prettyFile(request)))),
@@ -17,7 +17,8 @@ import { serveTeamFeed } from './team-feed.ts'
17
17
  import { type ComparedSide, PrettyError, compared, prettyRequest, prettyText } from './pretty-print.ts'
18
18
  import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
19
19
  import { typeOnlyFiles } from './type-only.ts'
20
- import { sharedTeamApp } from './team-app.ts'
20
+ import { comparisonBody } from './comparison.ts'
21
+ import { sharedTeamApp, withWorkspaceSummary } from './team-app.ts'
21
22
 
22
23
  function json(res: ServerResponse, status: number, body: unknown): void {
23
24
  sendJson(res, status, body, { 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
@@ -35,7 +36,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
35
36
  const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
36
37
  const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
37
38
  const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
38
- const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only)$/u.exec(url.pathname)
39
+ const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only|comparison)$/u.exec(url.pathname)
39
40
  if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
40
41
  const tokenHash = hashToken(token)
41
42
  const snapshot = await deps.db.getWorkspaceShare(tokenHash)
@@ -46,8 +47,8 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
46
47
  }
47
48
  const send = async (body: unknown) => { await recheck(); json(res, 200, body) }
48
49
  const sendTeam = async () => {
49
- const [summaries, app] = await Promise.all([bundleSummaries(snapshot.team.bundles, deps.bundleCache), sharedTeamApp(deps.db, deps.reportStore, snapshot)])
50
- await send({ user: snapshot.user, team: { ...snapshot.team, app,
50
+ const [summaries, workspace] = await Promise.all([bundleSummaries(snapshot.team.bundles, deps.bundleCache), sharedTeamApp(deps.db, deps.reportStore, snapshot)])
51
+ await send({ user: snapshot.user, team: { ...withWorkspaceSummary(snapshot.team, workspace),
51
52
  bundles: snapshot.team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })),
52
53
  } })
53
54
  await backfillBundleSummaries(snapshot.team.bundles, deps.bundleCache)
@@ -101,7 +102,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
101
102
  const bundle = snapshot.bundles.find(item => item.id === bundleRoute![1])
102
103
  if (!bundle) { json(res, 404, { error: 'no-bundle' }); return }
103
104
  if (bundleRoute![2] === 'advisories' && !snapshot.permissions.security) { json(res, 403, { error: 'security-access-required' }); return }
104
- if (bundleRoute![2] === 'rename-diff' || bundleRoute![2] === 'type-only' || (bundleRoute![2] === 'pretty' && url.searchParams.has('other'))) {
105
+ if (bundleRoute![2] === 'rename-diff' || bundleRoute![2] === 'type-only' || bundleRoute![2] === 'comparison' || (bundleRoute![2] === 'pretty' && url.searchParams.has('other'))) {
105
106
  if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
106
107
  await serveCompared(res, deps, bundle, snapshot.bundles.find(item => item.id === url.searchParams.get('other')), bundleRoute![2], url, recheck, stream); return
107
108
  }
@@ -109,11 +110,12 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
109
110
  }
110
111
 
111
112
  // What a bundle is compared with `other`, another of the link's bundles, by:
112
- // one of their JavaScript files diffed with renamed names hidden, or the
113
- // files that changed between them, or the pairs of those, or those that
114
- // changed in types alone, a line each.
113
+ // their files paired, as one comparison; one of their JavaScript files diffed
114
+ // with renamed names hidden; or the files that changed between them, or the
115
+ // pairs of those, or those that changed in types alone, a line each.
115
116
  function serveCompared(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, part: string, url: URL,
116
117
  recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
118
+ if (part === 'comparison') return serveComparison(res, deps, bundle, other, url, stream)
117
119
  if (part === 'rename-diff' && url.searchParams.has('path')) return serveRenameDiff(res, deps, bundle, other, url, stream)
118
120
  return serveChanges(res, deps, bundle, other, recheck, part === 'pretty' ? compared : part === 'type-only' ? typeOnlyFiles : renamedPairs,
119
121
  part === 'pretty' ? '' : url.searchParams.get('scope') ?? '')
@@ -139,6 +141,22 @@ async function serveRenameDiff(res: ServerResponse, deps: ManagedHttpDeps, bundl
139
141
  await stream(diff, 'br')
140
142
  }
141
143
 
144
+ // A bundle's files paired with `other`'s, another of the link's bundles, as
145
+ // Compare pairs them (comparison.ts), within a reason `scope` where given.
146
+ async function serveComparison(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, url: URL,
147
+ stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
148
+ if (!other) { json(res, 404, { error: 'no-bundle' }); return }
149
+ const { prettyCache } = deps
150
+ if (!prettyCache) { json(res, 503, { error: 'unavailable' }); return }
151
+ let body
152
+ try { body = await comparisonBody(await prettyCache.bundleDetails(bundle), await prettyCache.bundleDetails(other), url.searchParams.get('scope') ?? '') }
153
+ catch (err) {
154
+ if (err instanceof PrettyError) { json(res, err.status, { error: err.message }); return }
155
+ throw err
156
+ }
157
+ await stream(body, 'br')
158
+ }
159
+
142
160
  // The files that changed between a bundle and `other`, another of the link's
143
161
  // bundles, both sides' pretty-printed (pretty-print.ts compared), or the pairs
144
162
  // of them its viewer diffs with renamed names hidden (rename-diff.ts
@@ -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
+ }