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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +5 -1
  5. package/common/line-diff.js +5 -4
  6. package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
  7. package/out/{chunk-AFLZH7WG.js → chunk-4UFAMJAX.js} +1 -1
  8. package/out/{chunk-HMAJDVXN.js → chunk-7XRR4OFU.js} +1 -1
  9. package/out/{chunk-Q6MVYGYV.js → chunk-BOIXMUU5.js} +1 -1
  10. package/out/{chunk-XVBEOGOS.js → chunk-CC4OM6TZ.js} +2 -2
  11. package/out/chunk-G24A42V6.js +2 -0
  12. package/out/{chunk-WI7Z5A2T.js → chunk-JKGQ3O7O.js} +1 -1
  13. package/out/chunk-LE5ZWLG4.js +235 -0
  14. package/out/chunk-MSION3G4.js +36 -0
  15. package/out/{chunk-6HNXNIQY.js → chunk-S6GIXWQW.js} +1 -1
  16. package/out/chunk-V3KWNNMY.js +1 -0
  17. package/out/client-managed-import.js +1 -1
  18. package/out/client-managed.js +25 -25
  19. package/out/compare.js +13 -13
  20. package/out/dialogs.js +40 -40
  21. package/out/graph.js +1 -1
  22. package/out/index.html +6 -6
  23. package/out/scan.js +2 -2
  24. package/out/terminal.js +1 -1
  25. package/out/view.css +1 -1
  26. package/out/view.js +15 -15
  27. package/package.json +3 -1
  28. package/risk/actions/findings.js +14 -9
  29. package/risk/actions/scripts/shell.js +5 -1
  30. package/risk/index.d.ts +5 -2
  31. package/server-e2e/app.ts +5 -4
  32. package/server-e2e/http.ts +5 -2
  33. package/server-managed/bundle-cache.ts +7 -1
  34. package/server-managed/combined.ts +3 -2
  35. package/server-managed/commit-lockfiles.ts +21 -11
  36. package/server-managed/config.ts +6 -0
  37. package/server-managed/db-methods.ts +37 -9
  38. package/server-managed/db-neon.ts +61 -21
  39. package/server-managed/db-schema.ts +4 -0
  40. package/server-managed/db.ts +2 -1
  41. package/server-managed/dependency-reviews.ts +13 -3
  42. package/server-managed/github-app.ts +12 -6
  43. package/server-managed/github-issue-links.ts +11 -5
  44. package/server-managed/github-pulls.ts +2 -2
  45. package/server-managed/github-requests.ts +39 -0
  46. package/server-managed/http-response.ts +10 -2
  47. package/server-managed/http.ts +195 -111
  48. package/server-managed/index.ts +8 -7
  49. package/server-managed/kept-briefly.ts +37 -0
  50. package/server-managed/npm-delta.ts +11 -6
  51. package/server-managed/npm-kept.ts +32 -2
  52. package/server-managed/npm-loads.ts +100 -30
  53. package/server-managed/npm-packages.ts +89 -3
  54. package/server-managed/npm-risk.ts +4 -1
  55. package/server-managed/pretty-print.ts +67 -23
  56. package/server-managed/pull-dependencies.ts +29 -9
  57. package/server-managed/pull-dependency-reviews.ts +8 -8
  58. package/server-managed/queue.ts +16 -0
  59. package/server-managed/rename-diff.ts +22 -13
  60. package/server-managed/report-sources.ts +1 -1
  61. package/server-managed/report-store.ts +42 -9
  62. package/server-managed/repository-actions.ts +15 -6
  63. package/server-managed/repository-browser.ts +119 -31
  64. package/server-managed/repository-dependency-reviews.ts +41 -19
  65. package/server-managed/repository-history.ts +24 -8
  66. package/server-managed/repository-pulse.ts +8 -1
  67. package/server-managed/repository-risk-audit.ts +13 -0
  68. package/server-managed/repository-risk-read.ts +11 -6
  69. package/server-managed/repository-risk.ts +27 -20
  70. package/server-managed/session.ts +18 -4
  71. package/server-managed/sql.ts +5 -2
  72. package/server-managed/storage.ts +4 -2
  73. package/server-managed/team-reports.ts +24 -12
  74. package/server-managed/upstream-cache.ts +12 -0
  75. package/server.ts +14 -5
  76. package/out/chunk-5DHL5NG3.js +0 -2
  77. package/out/chunk-NV7ES4EW.js +0 -1
  78. package/out/chunk-PTGDYLNS.js +0 -36
  79. package/out/chunk-QRD6GIG5.js +0 -229
@@ -12,16 +12,17 @@ import { promisify } from 'node:util'
12
12
  import { brotliDecompress } from 'node:zlib'
13
13
  import { format } from '@preventive/compare/pretty.js'
14
14
  import { computeFileHash } from '@preventive/report'
15
- import { FILE_HASH } from '../common/bundle-metadata.js'
15
+ import { FILE_HASH, computeBundleFileHashes } from '../common/bundle-metadata.js'
16
16
  import { bundleCompareFiles } from '../common/bundle-compare-inputs.js'
17
17
  import { bundlePackageDirs, bundleSourcesAsMap } from '../common/bundle-sources.js'
18
18
  import { MAX_PRETTY_BYTES, needsPrettyPrint, prettyExtension } from '../common/pretty-print.js'
19
19
  import type { OpenedBlob } from './blob-store.ts'
20
- import { type BundleCacheStorage, readBundleDetails } from './bundle-cache.ts'
20
+ import { type BundleCacheStorage, readSizedBundleDetails } from './bundle-cache.ts'
21
21
  import type { BundleStore } from './bundle-store.ts'
22
22
  import { encodeBrotli } from './brotli.ts'
23
23
  import { CacheMissError, type CacheStorage } from './cache-storage.ts'
24
24
  import type { ManagedBundle, ManagedDb } from './db.ts'
25
+ import { keptBriefly } from './kept-briefly.ts'
25
26
  import { loadNpmFiles } from './npm-loads.ts'
26
27
  import { type NpmVersionDocument, npmFileText } from './npm-packages.ts'
27
28
 
@@ -34,6 +35,16 @@ const PRETTY_VERSION = 4
34
35
  // after another, beside other readers'), any more refused.
35
36
  const MAX_ACTIVE_FORMATS = 2
36
37
  const MAX_WAITING_FORMATS = 16
38
+ // How long a bundle's details are kept once read, and what the two last read
39
+ // may have decoded from between them, the least lately read going first past
40
+ // it: Compare's requests for a pair of bundles come one after another, each
41
+ // reading both, and the Code tab's for one, each reading it.
42
+ const DETAILS_KEPT_MS = 60_000
43
+ const MAX_KEPT_DETAILS = 2
44
+ const MAX_KEPT_DETAILS_BYTES = 256 * 1024 * 1024
45
+ // Files hashed at once: one at a time, each waits its own round trip to the
46
+ // thread pool; all at once, every one's job is held together.
47
+ const HASHES_AT_ONCE = 32
37
48
 
38
49
  export class PrettyError extends Error {
39
50
  status: number
@@ -108,6 +119,18 @@ export async function prettyBody(text: string, extension: string): Promise<Buffe
108
119
 
109
120
  const opened = (body: Buffer): OpenedBlob => ({ size: body.byteLength, stream: Readable.from([body]) })
110
121
 
122
+ // A version's file's hash, by its bytes: those of a version's files loaded
123
+ // a little while since are the same bytes (npm-loads.ts), hashed once.
124
+ const fileHashes = new WeakMap<Uint8Array, Promise<string>>()
125
+ function npmFileHash(bytes: Uint8Array): Promise<string> {
126
+ let hash = fileHashes.get(bytes)
127
+ if (!hash) {
128
+ fileHashes.set(bytes, hash = computeFileHash(bytes))
129
+ hash.catch(() => { fileHashes.delete(bytes) })
130
+ }
131
+ return hash
132
+ }
133
+
111
134
  const decompress = promisify(brotliDecompress)
112
135
 
113
136
  // A pretty-printed copy's text, from its Brotli-encoded bytes.
@@ -127,7 +150,7 @@ async function npmPrintable(doc: NpmVersionDocument): Promise<Array<PrettyReques
127
150
  const extension = prettyExtension(path)
128
151
  if (extension === null || bytes.byteLength > MAX_PRETTY_BYTES) continue
129
152
  const text = npmFileText(bytes)
130
- if (text !== null && needsPrettyPrint(path, text)) files.push({ path, hash: await computeFileHash(bytes), extension, text })
153
+ if (text !== null && needsPrettyPrint(path, text)) files.push({ path, hash: await npmFileHash(bytes), extension, text })
131
154
  }
132
155
  } finally { load.release() }
133
156
  return files.toSorted((a, b) => (a.path < b.path ? -1 : 1))
@@ -206,26 +229,35 @@ async function kept(open: () => Promise<OpenedBlob>): Promise<OpenedBlob | null>
206
229
 
207
230
  // A side's files that can be formatted, with their texts, and every one of
208
231
  // its files' hashes by path, from their paths and contents: texts, or bytes,
209
- // read as text where they are.
210
- async function sideFiles(entries: Iterable<[string, string | Uint8Array]>): Promise<Pick<ComparedSide, 'files' | 'hashes'>> {
232
+ // read as text where they are; hashed by `hashOf`, a few at once.
233
+ async function sideFiles<T extends string | Uint8Array>(entries: Array<[string, T]>, hashOf: (file: string, content: T) => Promise<string>): Promise<Pick<ComparedSide, 'files' | 'hashes'>> {
211
234
  const files: ComparedSide['files'] = [], hashes = new Map<string, string>()
235
+ for (let i = 0; i < entries.length; i += HASHES_AT_ONCE) {
236
+ const chunk = entries.slice(i, i + HASHES_AT_ONCE)
237
+ const hashed = await Promise.all(chunk.map(async ([path, content]) => [path, await hashOf(path, content)] as const))
238
+ for (const [path, hash] of hashed) hashes.set(path, hash)
239
+ }
212
240
  for (const [path, content] of entries) {
213
- const hash = await computeFileHash(content)
214
- hashes.set(path, hash)
215
241
  const extension = prettyExtension(path)
216
242
  if (extension === null || (typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength) > MAX_PRETTY_BYTES) continue
217
243
  const text = typeof content === 'string' ? content : npmFileText(content)
218
- if (text !== null) files.push({ path, hash, extension, text })
244
+ if (text !== null) files.push({ path, hash: hashes.get(path)!, extension, text })
219
245
  }
220
246
  return { files, hashes }
221
247
  }
222
248
 
223
249
  // A bundle's side, from its details: its files, as sideFiles reads them,
224
250
  // within a reason `scope` as Compare reads them there (bundleCompareFiles),
225
- // and its packages' directories.
251
+ // and its packages' directories; hashed once for its details, as long as
252
+ // they are kept (computeBundleFileHashes).
226
253
  async function bundleSide(details: Parameters<typeof bundlePackageDirs>[0], scope: string): Promise<Pick<ComparedSide, 'files' | 'hashes' | 'packageDirs'>> {
227
254
  const scoped = scope ? new Set(bundleCompareFiles(details, scope).keys()) : null, sources = bundleSourcesAsMap(details)
228
- return { ...await sideFiles(scoped ? [...sources].filter(([path]) => scoped.has(path)) : sources), packageDirs: bundlePackageDirs(details) }
255
+ const hashed: Map<string, string> = await computeBundleFileHashes(details)
256
+ return { ...await sideFiles(scoped ? [...sources].filter(([path]) => scoped.has(path)) : [...sources], (path, content) => {
257
+ const hash = hashed.get(path)
258
+ return hash === undefined ? computeFileHash(content) : Promise.resolve(hash)
259
+ }),
260
+ packageDirs: bundlePackageDirs(details) }
229
261
  }
230
262
 
231
263
  // A version's files by path, from one load of them.
@@ -251,14 +283,25 @@ export function stalePrettyRemover(npm: CacheStorage): (signal?: AbortSignal) =>
251
283
  }
252
284
 
253
285
  // A bundle's details, from its bytes in `store`: bundles are read one at a
254
- // time, as reading one decodes all of it.
286
+ // time, as reading one decodes all of it, readers asking for one waiting its
287
+ // turn or being read sharing it; and kept a little while once read
288
+ // (DETAILS_KEPT_MS), each reader's access to the bundle the caller's to check
289
+ // as ever.
255
290
  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
- })
291
+ type Details = NonNullable<Awaited<ReturnType<typeof readSizedBundleDetails>>>['details']
292
+ const enqueue = serial(), share = sharing<Details>()
293
+ const held = keptBriefly<Details>({ keptMs: DETAILS_KEPT_MS, maxBytes: MAX_KEPT_DETAILS_BYTES, maxEntries: MAX_KEPT_DETAILS })
294
+ return (record: ManagedBundle): Promise<Details> => {
295
+ const key = `${record.id} ${record.integrity}`
296
+ const hit = held.get(key)
297
+ if (hit) return Promise.resolve(hit)
298
+ return share(key, () => enqueue(async () => {
299
+ const read = await readSizedBundleDetails(record, store)
300
+ if (!read) throw new PrettyError(422, 'bundle-unavailable')
301
+ held.keep(key, read.details, read.decodedBytes)
302
+ return read.details
303
+ }))
304
+ }
262
305
  }
263
306
 
264
307
  export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage, db: Pick<ManagedDb, 'getBundle'>, store: BundleStore) {
@@ -317,7 +360,7 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
317
360
  try {
318
361
  const bytes = (await load.result).find(entry => entry.path === request.path)?.bytes
319
362
  if (!bytes) throw new PrettyError(404, 'no-file')
320
- if (await computeFileHash(bytes) !== request.hash) throw new PrettyError(409, 'hash-mismatch')
363
+ if (await npmFileHash(bytes) !== request.hash) throw new PrettyError(409, 'hash-mismatch')
321
364
  text = npmFileText(bytes)
322
365
  } finally { load.release() }
323
366
  if (text === null) throw new PrettyError(422, 'unformattable')
@@ -338,19 +381,20 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
338
381
  // pretty-printed (compared), and their diffs with renamed names hidden
339
382
  // (rename-diff.ts renamedPairs); a bundle's within a reason `scope`
340
383
  // where one is asked for, as Compare reads it then.
341
- async bundleSides(base: ManagedBundle, other: ManagedBundle, scope = ''): Promise<[ComparedSide, ComparedSide]> {
384
+ bundleSides(base: ManagedBundle, other: ManagedBundle, scope = ''): Promise<[ComparedSide, ComparedSide]> {
342
385
  const sideOf = async (record: ManagedBundle, side: Side): Promise<ComparedSide> => ({ side, ...await bundleSide(await bundleDetails(record), scope),
343
386
  kept: request => kept(() => bundles.open(record.id, prettyFile(request))),
344
387
  made: async (request, text) => opened(await bundleCopy(record, request, () => Promise.resolve(text))) })
345
- return [await sideOf(base, 'base'), await sideOf(other, 'other')]
388
+ return Promise.all([sideOf(base, 'base'), sideOf(other, 'other')])
346
389
  },
347
390
  // A bundle's details, read in its turn, as Compare pairs them (comparison.ts).
348
391
  bundleDetails,
349
- async npmSides(base: NpmVersionDocument, other: NpmVersionDocument): Promise<[ComparedSide, ComparedSide]> {
350
- const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc)), packageDirs: null,
392
+ npmSides(base: NpmVersionDocument, other: NpmVersionDocument): Promise<[ComparedSide, ComparedSide]> {
393
+ const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc), (_, bytes) => npmFileHash(bytes)),
394
+ packageDirs: null,
351
395
  kept: request => (doc.private ? Promise.resolve(null) : kept(() => npm.open(prettyFile(request)))),
352
396
  made: (request, text) => npmCopy(doc, request, () => Promise.resolve(text)) })
353
- return [await sideOf(base, 'base'), await sideOf(other, 'other')]
397
+ return Promise.all([sideOf(base, 'base'), sideOf(other, 'other')])
354
398
  },
355
399
  }
356
400
  }
@@ -109,6 +109,8 @@ export interface PullDependencyStore {
109
109
  // scan's are (repository-risk.ts riskCaches).
110
110
  type LockfileRead = { packages: LockedPackage[] } | { error: string; detail?: string }
111
111
  const lockfileReads: Lru<LockfileRead> = lru(200_000, read => 'packages' in read ? read.packages.length + 1 : 1)
112
+ // Those being read, by the token reading them too, lest each asking at once read them.
113
+ const lockfilesReading = new Map<string, Promise<LockfileRead>>()
112
114
 
113
115
  const api = (fullName: string) => `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
114
116
 
@@ -169,14 +171,26 @@ function lockfilesChanged(files: ChangedFile[]) {
169
171
  omitted: all.slice(MAX_DIRECTORIES).flatMap(([, directory]) => [...directory.paths]).toSorted() }
170
172
  }
171
173
 
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}`
174
+ // Whether `lockfile` at `ref` is held in this instance, read without asking
175
+ // GitHub: Bun's, never read, too.
176
+ export const lockfileHeld = (fullName: string, ref: string, { path, format }: { path: string; format: Format }) =>
177
+ format === 'bun' || lockfileReads.get(`${fullName}\0${ref}\0${path}`) !== undefined
178
+
179
+ // A lockfile's packages at `ref`, as read in this instance, else shared with
180
+ // a read of it with the same token still under way, else of GitHub; a
181
+ // lockfile its reader refuses, why. Any failure to read it of GitHub but its
182
+ // having none there is the whole answer's, as it would be every file's.
183
+ export function readLockfile(fullName: string, token: string, ref: string, lockfile: { path: string; format: Format }): Promise<LockfileRead> {
184
+ if (lockfile.format === 'bun') return Promise.resolve({ error: 'lockfile-unsupported' })
185
+ const key = `${fullName}\0${ref}\0${lockfile.path}`
178
186
  const known = lockfileReads.get(key)
179
- if (known) return known
187
+ if (known) return Promise.resolve(known)
188
+ const shared = `${token}\0${key}`
189
+ const reading = lockfilesReading.get(shared) ?? readOf(fullName, token, ref, lockfile, key).finally(() => lockfilesReading.delete(shared))
190
+ lockfilesReading.set(shared, reading)
191
+ return reading
192
+ }
193
+ async function readOf(fullName: string, token: string, ref: string, { path, format }: { path: string; format: Format }, key: string): Promise<LockfileRead> {
180
194
  let text: string
181
195
  try {
182
196
  text = await upstreamGithub(() => createClient({ token, userAgent: 'deepview-triage' }).getRepoFile({ repo: fullName, path, ref }))
@@ -261,10 +275,16 @@ function addedIn(paths: string[], before: LockfileRead[], after: LockfileRead[])
261
275
  // where it changes no lockfile read here.
262
276
  export async function pullDependencies(fullName: string, token: string, number: number, kept: PullDependencyStore,
263
277
  fetchImpl: typeof fetch = globalThis.fetch): Promise<PullDependencies> {
278
+ return (await pullDependenciesAt(fullName, token, number, kept, fetchImpl)).told
279
+ }
280
+ // The same, with the commits it is kept by (`commits`), its base's and
281
+ // head's, by which `kept` reads it.
282
+ export async function pullDependenciesAt(fullName: string, token: string, number: number, kept: PullDependencyStore,
283
+ fetchImpl: typeof fetch = globalThis.fetch): Promise<{ commits: string; told: PullDependencies }> {
264
284
  const { base, head } = await pullCommits(fullName, token, number, fetchImpl)
265
285
  const key = `${base}...${head}`
266
286
  const held = await kept.read(key)
267
- if (held) return held
287
+ if (held) return { commits: key, told: held }
268
288
  const { mergeBase, files, cut: filesCut } = await pullFiles(fullName, token, number, base, head, fetchImpl)
269
289
  const { directories, cut, omitted } = lockfilesChanged(files)
270
290
  const reads = directories.flatMap(({ before, after }) => [...before.map(lockfile => [mergeBase, lockfile] as const), ...after.map(lockfile => [head, lockfile] as const)])
@@ -276,5 +296,5 @@ export async function pullDependencies(fullName: string, token: string, number:
276
296
  })
277
297
  const told = { base: mergeBase, head, lockfiles, cut: filesCut || cut, ...cut && !filesCut && omitted.length <= MAX_OMITTED ? { omitted } : {} }
278
298
  await kept.write(key, told)
279
- return told
299
+ return { commits: key, told }
280
300
  }
@@ -24,7 +24,7 @@
24
24
  // changes: it is read once, every lockfile there of those read here
25
25
  // (pull-dependencies.ts), and kept by the commit (`baselines` in this
26
26
  // instance, `kept` beyond it), never stored as reviews.
27
- import { lockedAt, lockedIn } from './commit-lockfiles.ts'
27
+ import { type GithubAsked, lockedAt, lockedIn } from './commit-lockfiles.ts'
28
28
  import { GithubApiError } from './github-app.ts'
29
29
  import type { LockedPackage } from './locked-packages.ts'
30
30
  import { EVERY_LOCKFILE, type LockfileScope, heldTo, isEvery, reviewedIn } from './lockfile-scope.ts'
@@ -68,13 +68,13 @@ const baselineKeys = (pkg: LockedPackage) => hashesOf(pkg).map(hash => `${pkg.na
68
68
  // and kept; with the lockfiles there its reader refuses, isn't one of, or
69
69
  // past those read (`unread`). GitHub having none of a lockfile it lists
70
70
  // there is a failure of the whole, as of any file, never kept.
71
- export async function reviewBaseline(fullName: string, token: string, repoId: number, since: string, kept: ReviewBaselineStore): Promise<Baseline> {
71
+ export async function reviewBaseline(fullName: string, token: string, repoId: number, since: string, kept: ReviewBaselineStore, asked?: GithubAsked): Promise<Baseline> {
72
72
  const key = `${repoId}\0${since}`
73
73
  const known = baselines.get(key)
74
74
  if (known) return known
75
75
  const stored = await kept.read(key)
76
76
  if (stored) { const baseline = { versions: new Set(stored.versions), unread: stored.unread }; baselines.set(key, baseline); return baseline }
77
- const read = await lockedAt(fullName, token, since)
77
+ const read = await lockedAt(fullName, token, since, () => true, asked)
78
78
  const { packages } = lockedIn(read), { unread } = read
79
79
  const versions = new Set<string>()
80
80
  for (const pkg of packages) if (reviewedAs(pkg) === 'version') for (const held of baselineKeys(pkg)) versions.add(held)
@@ -298,18 +298,18 @@ export async function reviewedDependencies(told: PullDependencies, state: RepoRe
298
298
  ...unread.length > 0 && !reviewed ? { baselineUnread: unread.slice(0, MAX_UNREVIEWED) } : {}, ...isEvery(scope) ? {} : { lockfiles: scope } } }
299
299
  }
300
300
 
301
- // The packages a pull request's lockfiles lock that its reviews here may
302
- // pass, whose reviews are asked: those that can be reviewed, unpatched.
301
+ // The package versions a pull request's lockfiles lock that its reviews here
302
+ // may pass, whose reviews are asked: those that can be reviewed, unpatched.
303
303
  export const reviewedPackages = (told: PullDependencies) => told.lockfiles
304
- .flatMap(dir => 'error' in dir ? [] : dir.locked.filter(pkg => reviewedAs(pkg) === 'version' && !pkg.patchHash).map(pkg => pkg.name))
304
+ .flatMap(dir => 'error' in dir ? [] : dir.locked.filter(pkg => reviewedAs(pkg) === 'version' && !pkg.patchHash).map(pkg => ({ name: pkg.name, version: pkg.version! })))
305
305
 
306
306
  // A repository's baseline as read for a pull request: its versions and the
307
307
  // lockfiles there left unread; none where it reviews since no commit; and
308
308
  // where GitHub doesn't read it, none and why.
309
- export async function baselineFor(fullName: string, token: string, repoId: number, state: RepoReviewState, kept: ReviewBaselineStore) {
309
+ export async function baselineFor(fullName: string, token: string, repoId: number, state: RepoReviewState, kept: ReviewBaselineStore, asked?: GithubAsked) {
310
310
  if (!state.since) return { baseline: null, unread: [], error: null }
311
311
  try {
312
- const { versions, unread } = await reviewBaseline(fullName, token, repoId, state.since, kept)
312
+ const { versions, unread } = await reviewBaseline(fullName, token, repoId, state.since, kept, asked)
313
313
  return { baseline: versions, unread, error: null }
314
314
  } catch (err) {
315
315
  if (err instanceof GithubApiError && err.status !== 429 && err.status !== 401) return { baseline: null, unread: [], error: err.message }
@@ -21,3 +21,19 @@ export function queue<T>(signal: AbortSignal, { drain = false } = {}): Queue<T>
21
21
  },
22
22
  }
23
23
  }
24
+
25
+ // Work done `count` at a time, in the order asked, the rest waiting their
26
+ // turn.
27
+ export function inTurns(count: number): <T>(work: () => Promise<T>) => Promise<T> {
28
+ let active = 0
29
+ const waiting: (() => void)[] = []
30
+ return async work => {
31
+ if (active < count) active++
32
+ else await new Promise<void>(resolve => { waiting.push(resolve) })
33
+ try { return await work() } finally {
34
+ const next = waiting.shift()
35
+ if (next) next()
36
+ else active--
37
+ }
38
+ }
39
+ }
@@ -179,7 +179,10 @@ export async function renameDiffOf(request: RenameDiffRequest, texts: () => Prom
179
179
  return opened(await answerOf(request, texts, keep))
180
180
  }
181
181
 
182
- async function answerOf(request: RenameDiffRequest, texts: () => Promise<[string, string]>, keep: boolean): Promise<Buffer> {
182
+ // Sides `verified` to be the files the request names (renamedPairs) are read
183
+ // only where no answer is kept or on its way; others are read first, as
184
+ // reading them checks they are.
185
+ async function answerOf(request: RenameDiffRequest, texts: () => Promise<[string, string]>, keep: boolean, verified = false): Promise<Buffer> {
183
186
  // Sides not kept are formatted again for each diff (pretty-print.ts), so
184
187
  // they are read in its turn: the files a Diff view asks for at once wait
185
188
  // here rather than being refused by the formats running. Within its minute
@@ -193,21 +196,27 @@ async function answerOf(request: RenameDiffRequest, texts: () => Promise<[string
193
196
  })
194
197
  return encoded(diff, request, sides)
195
198
  }
196
- const [before, after] = await texts()
197
199
  // A file's type is part of how it is read: the same text as a script and as a module differ.
198
200
  const key = `${request.base.hash}\0${request.base.extension}\0${request.other.hash}\0${request.other.extension}`
199
- const found = kept.get(key)
200
- if (found) {
201
- kept.delete(key)
202
- kept.set(key, found)
203
- return found
201
+ const held = () => {
202
+ const found = kept.get(key)
203
+ if (found) {
204
+ kept.delete(key)
205
+ kept.set(key, found)
206
+ }
207
+ return found ?? pending.get(key)
204
208
  }
205
- let job = pending.get(key)
206
- if (!job) {
207
- const deadline = Date.now() + DIFF_TIMEOUT_MS
208
- pending.set(key, job = inTurn(deadline, () => inWorker(before, after, request, deadline)).then(diff => encoded(diff, request, [before, after])).finally(() => pending.delete(key)))
209
+ let answer = verified ? held() : undefined
210
+ if (!answer) {
211
+ const [before, after] = await texts()
212
+ answer = held()
213
+ if (!answer) {
214
+ const deadline = Date.now() + DIFF_TIMEOUT_MS
215
+ const job = inTurn(deadline, () => inWorker(before, after, request, deadline)).then(diff => encoded(diff, request, [before, after])).finally(() => pending.delete(key))
216
+ pending.set(key, answer = job)
217
+ }
209
218
  }
210
- const body = await job
219
+ const body = await answer
211
220
  kept.set(key, body)
212
221
  if (kept.size > KEPT_DIFFS) kept.delete(kept.keys().next().value!)
213
222
  return body
@@ -258,7 +267,7 @@ export function renamedPairs([base, other]: readonly [ComparedSide, ComparedSide
258
267
  const named = { path: from.path, hash: from.hash, otherPath: to.path, otherHash: to.hash }
259
268
  const copy = async (side: ComparedSide, { text, ...file }: typeof from) => prettyText(await side.kept(file) ?? await side.made(file, text))
260
269
  let body
261
- try { body = await answerOf(request, async () => [await copy(base, from), await copy(other, to)], keep) }
270
+ try { body = await answerOf(request, async () => [await copy(base, from), await copy(other, to)], keep, true) }
262
271
  catch (err) {
263
272
  if (!(err instanceof PrettyError)) throw err
264
273
  yield { ...named, error: err.message, status: err.status }
@@ -146,7 +146,7 @@ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, r
146
146
  }
147
147
  return {
148
148
  async open(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, sourcePaths?: Set<string>) {
149
- const repo = { github: (await db.listAllRepos()).find(entry => entry.repoId === report.repoId)?.fullName ?? null }
149
+ const repo = { github: report.repoId == null ? null : await db.getRepoFullName(report.repoId) }
150
150
  const target = sourceCacheFilename(report, bundle, permissions, repo, sourcePaths)
151
151
  try { return { ...await storage.open(target), repo } }
152
152
  catch (error) { if (!(error instanceof CacheMissError)) throw error }
@@ -15,33 +15,66 @@ async function readBytes(stored: OpenedBlob): Promise<Buffer> {
15
15
  } finally { stored.stream.destroy() }
16
16
  }
17
17
 
18
+ // Ids whose original copy is known gone, at most this many.
19
+ const COMPRESSED_ONLY_MAX = 16_384
20
+
18
21
  export function createReportStore(legacy: BlobStore, compressed: BlobStore,
19
22
  replaceLegacy: (id: string, bytes: Buffer) => Promise<unknown>): BlobStore {
23
+ // Reports this store wrote, converted, or found with only the compressed
24
+ // copy. Uploads write only that copy and nothing writes an original again,
25
+ // so their reads stop asking storage for one, each a miss (and, encrypted,
26
+ // a row read too). A compressed copy missing there asks for the original
27
+ // after all. An original left by a conversion that failed is never in it:
28
+ // the next read finds that original and resumes the conversion.
29
+ const compressedOnly = new Set<string>()
30
+ function remember(id: string): void {
31
+ compressedOnly.delete(id)
32
+ if (compressedOnly.size >= COMPRESSED_ONLY_MAX) compressedOnly.delete(compressedOnly.values().next().value!)
33
+ compressedOnly.add(id)
34
+ }
35
+ async function openCompressed(id: string): Promise<OpenedBlob | null> {
36
+ const stored = await compressed.open(id)
37
+ if (!stored) return null
38
+ const stream = createBrotliDecompress()
39
+ // pipeline carries storage/authentication errors into the decoded stream
40
+ // and closes the stored source when a reader leaves before EOF.
41
+ void pipeline(stored.stream, stream).catch(() => {})
42
+ return { size: null, stream }
43
+ }
20
44
  async function open(id: string): Promise<OpenedBlob | null> {
45
+ if (compressedOnly.has(id)) {
46
+ const stored = await openCompressed(id)
47
+ if (stored) return stored
48
+ compressedOnly.delete(id)
49
+ }
21
50
  // Keep the original available until conversion succeeds. Concurrent reads
22
51
  // can finish using an already-open stream while another reader converts it.
23
52
  const original = await legacy.open(id)
24
53
  if (original) {
25
54
  const bytes = await readBytes(original)
26
55
  await replaceLegacy(id, await encodeBrotli(bytes, 9))
56
+ remember(id)
27
57
  return { size: bytes.length, stream: Readable.from([bytes]) }
28
58
  }
29
- const stored = await compressed.open(id)
30
- if (!stored) return null
31
- const stream = createBrotliDecompress()
32
- // pipeline carries storage/authentication errors into the decoded stream
33
- // and closes the stored source when a reader leaves before EOF.
34
- void pipeline(stored.stream, stream).catch(() => {})
35
- return { size: null, stream }
59
+ const stored = await openCompressed(id)
60
+ if (stored) remember(id)
61
+ return stored
36
62
  }
37
63
  return {
38
- put: async (id, bytes) => compressed.put(id, await encodeBrotli(bytes, 9)),
64
+ async put(id, bytes) {
65
+ const wrapped = await compressed.put(id, await encodeBrotli(bytes, 9))
66
+ remember(id)
67
+ return wrapped
68
+ },
39
69
  open,
40
70
  async get(id) {
41
71
  const stored = await open(id)
42
72
  if (!stored) return null
43
73
  return readBytes(stored)
44
74
  },
45
- async delete(id) { await Promise.all([compressed.delete(id), legacy.delete(id)]) },
75
+ async delete(id) {
76
+ compressedOnly.delete(id)
77
+ await Promise.all([compressed.delete(id), legacy.delete(id)])
78
+ },
46
79
  }
47
80
  }
@@ -92,7 +92,8 @@ export function actionsLines(repo: number, owner: string, read: WorkflowsRead, l
92
92
  if ('error' in read) return { lines: [{ type: 'workflows', repo, ...read } as WorkflowsLine], refs: [] as ActionRef[] }
93
93
  const lines: (WorkflowsLine | WorkflowLine)[] = [{ type: 'workflows', repo, paths: read.files.filter(file => file.kind === 'workflow').map(file => file.path) }]
94
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] : []))
95
+ // Its own actions by their directories and workflows by their paths, as a step or a job uses one.
96
+ const actions = new Map(read.files.flatMap(file => file.kind !== 'dockerfile' && 'text' in file ? [[file.kind === 'action' ? actionDir(file.path) : file.path, file.text] as const] : []))
96
97
  for (const file of read.files) {
97
98
  const scan = 'error' in file ? file : file.kind === 'dockerfile' ? { uses: [], findings: dockerfileFindings(file.text) } : scanWorkflow(file.kind, file.text, { owner, lockfiles, actions })
98
99
  lines.push({ type: 'workflow', repo, path: file.path, kind: file.kind, ...scan })
@@ -151,10 +152,12 @@ export interface ActionsResolving {
151
152
  store?: KeptStore<ActionLine> | undefined
152
153
  }
153
154
 
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 })
155
+ // A reference's line: kept (`stored`, as read of the store with others),
156
+ // else resolved, and kept where it is a public repository's; why not, where
157
+ // it couldn't be.
158
+ async function resolved(resolving: ActionsResolving, source: ActionsSource, { key, owner }: ActionRef,
159
+ stored = resolving.store?.read(key)): Promise<ActionLine | null> {
160
+ const kept = resolving.memory.get(key) ?? await stored?.then(row => row && { at: row.at, line: row.value })
158
161
  if (kept && Date.now() - kept.at < RISK_KEPT_MS) { resolving.memory.set(key, kept); return kept.line }
159
162
  const uses = usesOfKey(key)
160
163
  if (!uses) return { type: 'action', key, error: 'invalid-uses' }
@@ -180,6 +183,9 @@ export function resolvingActions(resolving: ActionsResolving) {
180
183
  const source = keptSource(sharedSource(resolving.source, resolving.answers))
181
184
  const pending: Queue<ActionRef> = queue(resolving.signal)
182
185
  const latest = new Map<string, ActionRef>()
186
+ // Those of the references added at once kept beyond this instance, read of
187
+ // the store together as they are added.
188
+ const stored = new Map<string, Promise<{ at: number; value: ActionLine } | null>>()
183
189
  let closed = false, waiting = 0
184
190
  const settle = () => { if (closed && waiting === 0) pending.close() }
185
191
  const add = (refs: ActionRef[]) => {
@@ -191,11 +197,14 @@ export function resolvingActions(resolving: ActionsResolving) {
191
197
  waiting++
192
198
  pending.add(kept)
193
199
  }
200
+ const unread = [...new Set(refs.map(ref => ref.key))].filter(key => latest.has(key) && !stored.has(key) && !resolving.memory.get(key))
201
+ const read = unread.length > 0 ? resolving.store?.readMany(unread) : undefined
202
+ for (const key of read ? unread : []) stored.set(key, read!.then(found => found.get(key) ?? null))
194
203
  }
195
204
  const work = async () => {
196
205
  for (let ref = await pending.take(); ref !== undefined; ref = await pending.take()) {
197
206
  try {
198
- const line = await resolved(resolving, source, ref)
207
+ const line = await resolved(resolving, source, ref, stored.get(ref.key))
199
208
  if (line && latest.get(ref.key) === ref) {
200
209
  resolving.tell(line)
201
210
  const owner = 'repository' in line ? (line.repository?.fullName ?? line.repo).split('/')[0]! : ref.owner