@preventive/triage 1.0.0-alpha.47 → 1.0.0-alpha.49

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 (82) hide show
  1. package/client/dependency-paths.d.ts +2 -1
  2. package/client/dependency-paths.js +16 -4
  3. package/common/managed/agent-tasks.ts +12 -14
  4. package/common/managed/github-agents.ts +7 -6
  5. package/out/activity.js +4 -4
  6. package/out/{chunk-NOZJULWS.js → chunk-6LNQTKAV.js} +1 -1
  7. package/out/chunk-7VAWINQF.js +6 -0
  8. package/out/{chunk-5AHIQFFG.js → chunk-C2AVODGZ.js} +1 -1
  9. package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
  10. package/out/chunk-JEAUAJVF.js +232 -0
  11. package/out/chunk-O6M574JI.js +1 -0
  12. package/out/{chunk-VL43BTSO.js → chunk-PBSOL2SL.js} +1 -1
  13. package/out/{chunk-ZCS4UYHI.js → chunk-PBZPYIG7.js} +1 -1
  14. package/out/{chunk-6WF3KNTL.js → chunk-SZD5CW5R.js} +2 -2
  15. package/out/{chunk-SPEFPML6.js → chunk-TQJDYHVE.js} +1 -1
  16. package/out/{chunk-UENXT2HK.js → chunk-ULIF7A7N.js} +1 -1
  17. package/out/{chunk-FH24VHSJ.js → chunk-WYLQWYLA.js} +1 -1
  18. package/out/client-managed-import.js +1 -1
  19. package/out/client-managed.js +26 -26
  20. package/out/compare.js +13 -13
  21. package/out/dialogs.js +17 -17
  22. package/out/graph.js +1 -1
  23. package/out/index.html +7 -7
  24. package/out/scan.js +1 -1
  25. package/out/terminal.js +214 -214
  26. package/out/view.css +1 -1
  27. package/out/view.js +15 -15
  28. package/package.json +8 -5
  29. package/risk/npm/score.js +1 -1
  30. package/server-managed/agent-merges.ts +38 -22
  31. package/server-managed/app-agents.ts +6 -8
  32. package/server-managed/cache-storage.ts +3 -2
  33. package/server-managed/commit-coauthors.ts +7 -2
  34. package/server-managed/commit-lockfiles.ts +22 -21
  35. package/server-managed/db-methods.ts +52 -38
  36. package/server-managed/db-neon.ts +21 -5
  37. package/server-managed/db.ts +2 -0
  38. package/server-managed/dependabot-access.ts +1 -1
  39. package/server-managed/dependency-review-comments.ts +7 -4
  40. package/server-managed/dependency-reviews.ts +71 -33
  41. package/server-managed/github-accounts.ts +28 -1
  42. package/server-managed/github-app.ts +27 -23
  43. package/server-managed/github-graphql.ts +39 -0
  44. package/server-managed/github-issue-links.ts +8 -1
  45. package/server-managed/github-metadata.ts +2 -2
  46. package/server-managed/http-response.ts +28 -10
  47. package/server-managed/http.ts +751 -273
  48. package/server-managed/index.ts +2 -2
  49. package/server-managed/managed-issues.ts +3 -0
  50. package/server-managed/npm-delta.ts +16 -0
  51. package/server-managed/npm-kept.ts +32 -5
  52. package/server-managed/npm-loads.ts +21 -13
  53. package/server-managed/npm-manifest.ts +1 -1
  54. package/server-managed/npm-packages.ts +13 -4
  55. package/server-managed/object-storage.ts +4 -3
  56. package/server-managed/public-workspace.ts +3 -3
  57. package/server-managed/pull-comment-reviews.ts +1 -1
  58. package/server-managed/pull-dependencies.ts +84 -36
  59. package/server-managed/pull-dependency-reviews.ts +28 -18
  60. package/server-managed/rename-diff-worker.js +1 -1
  61. package/server-managed/repository-agents.ts +9 -6
  62. package/server-managed/repository-browser.ts +13 -3
  63. package/server-managed/repository-code.ts +153 -7
  64. package/server-managed/repository-dependency-reviews.ts +12 -4
  65. package/server-managed/repository-history.ts +85 -152
  66. package/server-managed/repository-issues.ts +27 -13
  67. package/server-managed/repository-pulse.ts +31 -11
  68. package/server-managed/repository-squashes.ts +145 -0
  69. package/server-managed/request-timing.ts +63 -0
  70. package/server-managed/reviewed-dependencies.ts +3 -2
  71. package/server-managed/session.ts +34 -12
  72. package/server-managed/sql.ts +16 -8
  73. package/server-managed/storage-encryption.ts +16 -8
  74. package/server-managed/storage-maintenance.ts +5 -2
  75. package/server-managed/storage-payload.ts +18 -0
  76. package/server-managed/storage-stores.ts +3 -2
  77. package/server-managed/team-feed.ts +16 -11
  78. package/server-managed/team-npm-scopes.ts +29 -0
  79. package/server-managed/team-reports.ts +68 -16
  80. package/out/chunk-6ANSHRFQ.js +0 -6
  81. package/out/chunk-S6U2J62U.js +0 -232
  82. package/out/chunk-ZGNMDGSB.js +0 -1
@@ -44,7 +44,7 @@ export async function createManagedApp(config: ManagedConfig, options: ManagedAp
44
44
 
45
45
  async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options }: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
46
46
  const storage = await openManagedStorage(config)
47
- const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage } = storage
47
+ const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage } = storage
48
48
  rollback.defer(() => db.close())
49
49
  const originGate = createOriginGate(config.host, config.trustProxyEnv)
50
50
 
@@ -60,7 +60,7 @@ async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options
60
60
  indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
61
61
  }) : () => false
62
62
  const routeRequest = createManagedRequestHandler({
63
- ...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, originGate, serveStatic,
63
+ ...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage, originGate, serveStatic,
64
64
  ...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}), ...(waitUntil ? { waitUntil } : {}),
65
65
  isShuttingDown, track,
66
66
  })
@@ -53,6 +53,9 @@ export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
53
53
  issue_url AS issueUrl, auto_fix_url AS autoFixUrl, auto_fix_checked_at AS autoFixCheckedAt, created_by AS createdBy, created_at AS createdAt`
54
54
  const get = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id = ?`)
55
55
  const list = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY finding_id`)
56
+ // A lookup's derived Fix replaces the one it read, unless one started later
57
+ // changed it since: the check time, which only changes stamp
58
+ // (github-issue-links.ts issueUpdates), orders them.
56
59
  const autoFix = db.prepare(`UPDATE managed_finding_issue SET auto_fix_url = ?, auto_fix_checked_at = ?
57
60
  WHERE finding_id = ? AND issue_url = ? AND COALESCE(auto_fix_url, '') = COALESCE(?, '')
58
61
  AND (auto_fix_checked_at IS NULL OR auto_fix_checked_at <= ?)`)
@@ -154,6 +154,22 @@ export function npmDelta(base: Side | null, other: Side, kept: KeptStore<NpmDelt
154
154
  return work
155
155
  }
156
156
 
157
+ // The deltas of those of `sides` (each the integrities of the version it is
158
+ // against, or none, and of the version) held or kept, by their keys
159
+ // (npmDelta's), in one read of `kept` for those not held: none worked out.
160
+ export async function keptNpmDeltas(sides: [string | null, string][], kept: KeptStore<NpmDelta>): Promise<Map<string, NpmDelta>> {
161
+ const keys = [...new Set(sides.map(([base, other]) => `${base ?? '(empty)'}\0${other}`))]
162
+ const found = new Map(keys.flatMap(key => { const known = deltas.get(key); return known ? [[key, known] as const] : [] }))
163
+ const unheld = keys.filter(key => !found.has(key))
164
+ for (const [key, row] of unheld.length > 0 ? await kept.readMany(unheld) : []) {
165
+ const known = deltaOf(row.value)
166
+ if (!known) continue
167
+ deltas.set(key, known)
168
+ found.set(key, known)
169
+ }
170
+ return found
171
+ }
172
+
157
173
  // As kept, else its turn awaited, then both sides' files read, counted and kept.
158
174
  async function worked(key: string, base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null): Promise<NpmDelta> {
159
175
  const known = deltaOf((await kept?.read(key))?.value)
@@ -11,10 +11,14 @@
11
11
  // of a repository's GitHub, its issues, pull requests and agents' work, for a
12
12
  // day (http.ts keptAnswer), and its history, a page from a commit at a time,
13
13
  // for a month (repository-history.ts historyFrom), as are the files each of
14
- // its commits changes and holds (repository-changes.ts), and the packages a
15
- // pull request's lockfiles add (pull-dependencies.ts). Each store removes its own rows kept longer
16
- // than that as it writes, at most once an hour a database (pruneKept), so
17
- // that rows no read will take again don't pile up.
14
+ // its commits changes and holds (repository-changes.ts), its trees as the
15
+ // code viewer reads them (codeTreeStore), the packages a pull request's
16
+ // lockfiles add (pull-dependencies.ts), and the lockfiles a commit lists and
17
+ // each one's packages, as its dependency review reads them
18
+ // (commit-lockfiles.ts); and the commit the code viewer keeps its
19
+ // directories at, for as long as it does (codeHeadStore). Each store removes
20
+ // its own rows kept longer than that as it writes, at most once an hour a
21
+ // database (pruneKept), so that rows no read will take again don't pile up.
18
22
  // The database is an optimization: a failure reads as a miss, and writes
19
23
  // nothing.
20
24
  import { createHash } from 'node:crypto'
@@ -155,7 +159,7 @@ export const repositoryReadStore = (db: UpstreamCacheStore, debug = false): Kept
155
159
  export const HISTORY_PAGE_KEPT_MS = 30 * 24 * 60 * 60_000
156
160
  export const historyPageStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/history', HISTORY_PAGE_KEPT_MS, debug)
157
161
  // The pull requests squashed into a repository's commits, by the commit
158
- // (repository-history.ts squashedPulls): kept as its history's pages are.
162
+ // (repository-squashes.ts squashedPulls): kept as its history's pages are.
159
163
  export const historySquashStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/squashes', HISTORY_PAGE_KEPT_MS, debug)
160
164
  // The files each of a repository's commits changes (repository-changes.ts
161
165
  // repositoryChanges), by the commit, which never change: kept as its
@@ -165,6 +169,21 @@ export const historyChangeStore = <T>(db: UpstreamCacheStore, debug = false): Ke
165
169
  // repositoryTree), by the commit, which never change: kept as its history's
166
170
  // pages are.
167
171
  export const historyTreeStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/trees', HISTORY_PAGE_KEPT_MS, debug)
172
+ // What the code viewer read of a repository's trees (http.ts
173
+ // repositoryTreesOf), which never changes once a commit or tree names it: the
174
+ // tree at a directory of a commit, by the repository's id, the commit and the
175
+ // directory (`trees`), and how many files a tree holds and their bytes, by
176
+ // the repository's id and the tree's (`tree-files`): kept as its history's
177
+ // pages are.
178
+ export const codeTreeStore = <T>(db: UpstreamCacheStore, type: 'trees' | 'tree-files', debug = false): KeptStore<T> =>
179
+ keptStore<T>(db, `repository/code-${type}`, HISTORY_PAGE_KEPT_MS, debug)
180
+ // The commit the code viewer keeps a repository's directories at in byte
181
+ // storage, its default branch's head as last read, and the repository's
182
+ // name they are kept under (repository-code.ts repositoryCodeKept), by the
183
+ // repository's id: kept until the head moves, the repository is renamed or
184
+ // it is removed, one for each repository.
185
+ export const codeHeadStore = (db: UpstreamCacheStore, debug = false): KeptStore<{ commit: string; repo: string }> =>
186
+ keptStore<{ commit: string; repo: string }>(db, 'repository/code-heads', Infinity, debug)
168
187
  // The packages a repository's pull request's lockfiles add
169
188
  // (pull-dependencies.ts), by the commits of its base and head, which never
170
189
  // change: kept as its history's pages are.
@@ -178,3 +197,11 @@ export const pullDependencyStore = <T>(db: UpstreamCacheStore, debug = false): K
178
197
  export const npmDeltaStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'npm/deltas', HISTORY_PAGE_KEPT_MS, debug)
179
198
  export const reviewBaselineStore = (db: UpstreamCacheStore, debug = false): KeptStore<{ versions: string[]; unread: string[] }> =>
180
199
  keptStore<{ versions: string[]; unread: string[] }>(db, 'repository/review-baselines', HISTORY_PAGE_KEPT_MS, debug)
200
+ // What a repository's dependency review read of GitHub (pull-dependencies.ts
201
+ // LockfileStore): the lockfiles a commit lists (`listings`), by the commit,
202
+ // and a lockfile's packages with what their reviews are held to
203
+ // (`lockfiles`), by its blob, which never change: kept as its history's
204
+ // pages are, apart from a scan's (scanReadStore), which reads them without
205
+ // what their reviews are held to.
206
+ export const lockfileReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'lockfiles', debug = false): KeptStore<T> =>
207
+ keptStore<T>(db, `repository/review-${type}`, HISTORY_PAGE_KEPT_MS, debug)
@@ -50,9 +50,9 @@ const refused = <T>(status: number, code: string): NpmLoad<T> => {
50
50
  return { result, release() {} }
51
51
  }
52
52
 
53
- // What names a version's tarball, and so its files: its integrity and URL,
54
- // and whether it is read with the token.
55
- const tarballKey = (doc: NpmVersionDocument) => `${doc.dist.integrity} ${doc.dist.tarball} ${doc.private}`
53
+ // What names a version's tarball, and so its files: its integrity, its
54
+ // shasum and URL, and whether it is read with the token.
55
+ const tarballKey = (doc: NpmVersionDocument) => `${doc.dist.integrity} ${doc.dist.shasum ?? ''} ${doc.dist.tarball} ${doc.private}`
56
56
 
57
57
  function shared<T>(what: string, doc: NpmVersionDocument, work: () => Promise<T>): NpmLoad<T> {
58
58
  const { unpackedSize, fileCount } = doc.dist
@@ -118,7 +118,13 @@ function done(key: string, load: Load) {
118
118
  // The sha512 an integrity names, in base64, or null where it names none.
119
119
  const sha512Of = (integrity: string) => /(?:^|\s)sha512-([\d+/A-Za-z]{86}==)(?=\s|$)/u.exec(integrity)?.[1] ?? null
120
120
  // Hashed off the event loop: a tarball runs to tens of MiB.
121
- const sha512 = async (bytes: Uint8Array) => Buffer.from(await crypto.subtle.digest('SHA-512', bytes as Uint8Array<ArrayBuffer>)).toString('base64')
121
+ const digest = async (algorithm: string, bytes: Uint8Array) => Buffer.from(await crypto.subtle.digest(algorithm, bytes as Uint8Array<ArrayBuffer>))
122
+ // Whether `bytes` are those of the sha512 an integrity names and, where there
123
+ // is one, of the shasum (a sha1, in hex).
124
+ async function hashedAs(bytes: Uint8Array, sha512: string, shasum: string | null): Promise<boolean> {
125
+ const [own512, own1] = await Promise.all([digest('SHA-512', bytes), shasum === null ? null : digest('SHA-1', bytes)])
126
+ return own512.toString('base64') === sha512 && (own1 === null || own1.toString('hex') === shasum)
127
+ }
122
128
 
123
129
  // Upstream's disk cache (setCacheDir), where tarballs are kept between
124
130
  // loads, or null to keep none and read none.
@@ -151,23 +157,25 @@ async function downloadTarball(url: string, isPrivate: boolean): Promise<Buffer>
151
157
  return bytes
152
158
  }
153
159
 
154
- // The version's tarball, its bytes held to the document's sha512 wherever
155
- // they come from: kept in the cache, where bundle builds keep what they
156
- // fetch with the token, or in npm's; else downloaded, and then kept. Bytes
157
- // matching the sha512 the reader's own document names are the tarball that
158
- // reader may download, wherever they were kept and whoever fetched them.
160
+ // The version's tarball, its bytes held to the document's sha512, and to its
161
+ // shasum where it tells one, wherever they come from: kept in the cache,
162
+ // where bundle builds keep what they fetch with the token, or in npm's; else
163
+ // downloaded, and then kept. Bytes matching the hashes the reader's own
164
+ // document names are the tarball that reader may download, wherever they
165
+ // were kept and whoever fetched them.
159
166
  async function readTarball(doc: NpmVersionDocument): Promise<Uint8Array> {
160
- const expected = sha512Of(doc.dist.integrity), url = doc.dist.tarball
161
- if (!url.startsWith(`${NPM_REGISTRY}/${doc.name}/-/`) || URL.parse(url)?.href !== url || expected === null) throw new NpmPackageError(502, 'upstream-invalid')
167
+ const expected = sha512Of(doc.dist.integrity), shasum = doc.dist.shasum ?? null, url = doc.dist.tarball
168
+ if (!url.startsWith(`${NPM_REGISTRY}/${doc.name}/-/`) || URL.parse(url)?.href !== url || expected === null
169
+ || (shasum !== null && !/^[\da-f]{40}$/u.test(shasum))) throw new NpmPackageError(502, 'upstream-invalid')
162
170
  const kept = tarballCache === null ? null : upstreamCachePath(tarballCache, 'npm/tarballs', `${doc.name}@${doc.version}.tgz`)
163
171
  if (kept !== null) {
164
172
  for (const path of [kept, npmCachePath(expected)]) {
165
173
  const bytes = path === null ? null : await readKeptFile(path, MAX_TAR_BYTES)
166
- if (bytes && await sha512(bytes) === expected) return bytes
174
+ if (bytes && await hashedAs(bytes, expected, shasum)) return bytes
167
175
  }
168
176
  }
169
177
  const bytes = await downloadTarball(url, doc.private)
170
- if (await sha512(bytes) !== expected) throw new NpmPackageError(502, 'upstream-invalid')
178
+ if (!await hashedAs(bytes, expected, shasum)) throw new NpmPackageError(502, 'upstream-invalid')
171
179
  if (kept !== null) await keepFile(kept, bytes)
172
180
  return bytes
173
181
  }
@@ -102,7 +102,7 @@ export function npmManifest(json: Record<string, unknown>): Record<string, unkno
102
102
  // as they read npm's.
103
103
  const VERSION_FIELDS = ['name', 'version', 'description', 'license', 'homepage', 'main', 'module', 'types', 'type', 'deprecated', 'gitHead', 'author', '_npmUser',
104
104
  'maintainers', 'keywords', 'bin', 'dependencies', 'peerDependencies', 'optionalDependencies', 'engines', 'hasInstallScript', 'repository', 'bugs']
105
- const DIST_FIELDS = ['tarball', 'integrity', 'unpackedSize', 'fileCount']
105
+ const DIST_FIELDS = ['tarball', 'integrity', 'shasum', 'unpackedSize', 'fileCount']
106
106
  const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall']
107
107
  const picked = (json: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(key => Object.hasOwn(json, key)).map(key => [key, json[key]]))
108
108
 
@@ -228,7 +228,9 @@ export interface NpmVersionDocument {
228
228
  name: string
229
229
  version: string
230
230
  private: boolean
231
- dist: { tarball: string; integrity: string; unpackedSize: number | null; fileCount: number | null }
231
+ // `shasum`, the tarball's sha1 in hex, where npm tells it: held, with the
232
+ // integrity, to the tarball its files are read from (npm-loads.ts).
233
+ dist: { tarball: string; integrity: string; shasum: string | null; unpackedSize: number | null; fileCount: number | null }
232
234
  manifest: Record<string, unknown>
233
235
  }
234
236
 
@@ -238,10 +240,12 @@ const count = (value: unknown) => Number.isSafeInteger(value) && (value as numbe
238
240
  function versionDocument(name: string, json: Record<string, unknown>, isPrivate: boolean): NpmVersionDocument {
239
241
  const dist = json['dist']
240
242
  if (json['name'] !== name || typeof json['version'] !== 'string' || !isNpmPackageSpec(json['version']) || !plainObject(dist)
241
- || typeof dist['tarball'] !== 'string' || typeof dist['integrity'] !== 'string') throw new NpmPackageError(502, 'upstream-invalid')
243
+ || typeof dist['tarball'] !== 'string' || typeof dist['integrity'] !== 'string'
244
+ || (dist['shasum'] !== undefined && typeof dist['shasum'] !== 'string')) throw new NpmPackageError(502, 'upstream-invalid')
242
245
  return {
243
246
  name, version: json['version'], private: isPrivate,
244
- dist: { tarball: dist['tarball'], integrity: dist['integrity'], unpackedSize: count(dist['unpackedSize']), fileCount: count(dist['fileCount']) },
247
+ dist: { tarball: dist['tarball'], integrity: dist['integrity'], shasum: typeof dist['shasum'] === 'string' ? dist['shasum'] : null,
248
+ unpackedSize: count(dist['unpackedSize']), fileCount: count(dist['fileCount']) },
245
249
  manifest: npmManifest(json),
246
250
  }
247
251
  }
@@ -262,13 +266,18 @@ export interface NpmVersionStore {
262
266
  // changes a version's files, but its document can change: a deprecation, the
263
267
  // package made private (a scoped one can be), the version removed (which whoever
264
268
  // reads it from a store learns elsewhere too: risk does). A day bounds each.
269
+ // One kept with no shasum, as none was before it was kept (npmVersionFields),
270
+ // is asked of npm again, which tells one of each version.
265
271
  export const VERSION_KEPT_MS = 24 * 60 * 60_000
266
272
  export function readNpmVersion(name: string, spec: string, privileged: boolean, signal: AbortSignal, store?: NpmVersionStore): Promise<NpmVersionDocument | null> {
267
273
  if (!isNpmPackageName(name) || !isNpmPackageSpec(spec)) return Promise.reject(new NpmPackageError(400, 'bad-package'))
268
274
  // A kept one is read within the same budget as npm's answer.
269
275
  return withDocumentBudget(VERSION_DOCUMENT_BYTES, async () => {
270
276
  const kept = store && isExactVersion(spec) ? await store.read(name, spec) : null
271
- if (kept && Date.now() - kept.at < VERSION_KEPT_MS) try { return versionDocument(name, kept.json, false) } catch {}
277
+ const keptDist = kept?.json['dist']
278
+ if (kept && Date.now() - kept.at < VERSION_KEPT_MS && plainObject(keptDist) && typeof keptDist['shasum'] === 'string') {
279
+ try { return versionDocument(name, kept.json, false) } catch {}
280
+ }
272
281
  const found = await readDocument(name, registryUrl(name, spec), privileged, { accept: 'application/json', limit: VERSION_DOCUMENT_BYTES, signal })
273
282
  if (!found) return null
274
283
  const doc = versionDocument(name, found.json, found.private)
@@ -30,7 +30,8 @@ export interface ObjectStorage {
30
30
  exists(key: string): Promise<boolean>
31
31
  put(key: string, bytes: Buffer, options?: { replaceReport?: boolean }): Promise<string | null>
32
32
  delete(key: string): Promise<void>
33
- deletePrefix(prefix: string): Promise<void>
33
+ // Every object under `prefix`, but those under `keep`, a prefix within it.
34
+ deletePrefix(prefix: string, keep?: string): Promise<void>
34
35
  }
35
36
 
36
37
  export function isBlobId(value: string): boolean {
@@ -52,14 +53,14 @@ export async function openObjectVersion(raw: RawObjectStorage, key: string, vers
52
53
  return null
53
54
  }
54
55
 
55
- export async function deleteObjects(raw: RawObjectStorage, prefix: string, signal: AbortSignal, before?: number): Promise<number> {
56
+ export async function deleteObjects(raw: RawObjectStorage, prefix: string, signal: AbortSignal, before?: number, keep?: string): Promise<number> {
56
57
  const eligible = (modifiedAt: number) => before === undefined || modifiedAt < before
57
58
  // Finish listing before deleting: provider cursors may be offset-based.
58
59
  const cursors = new Set<string>(), keys = new Set<string>()
59
60
  let cursor: string | null = null
60
61
  do {
61
62
  const page = await raw.list(prefix, cursor, 100, signal)
62
- for (const object of page.objects) if (eligible(object.modifiedAt)) keys.add(object.key)
63
+ for (const object of page.objects) if (eligible(object.modifiedAt) && (keep === undefined || !object.key.startsWith(keep))) keys.add(object.key)
63
64
  cursor = page.cursor
64
65
  if (cursor !== null && cursors.has(cursor)) throw new Error('Invalid blob pagination')
65
66
  if (cursor !== null) cursors.add(cursor)
@@ -7,7 +7,7 @@ import type { OpenedBlob } from './blob-store.ts'
7
7
  import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
8
8
  import { sendJson, sendLines, writeResponse } from './http-response.ts'
9
9
  import { hashToken } from './crypto.ts'
10
- import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, teamReportVisibility } from './team-reports.ts'
10
+ import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, loadTeamTreesResponse, teamReportVisibility } from './team-reports.ts'
11
11
  import { triageWireEntry } from './triage-response.ts'
12
12
  import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
13
13
  import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
@@ -34,7 +34,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
34
34
  const method = req.method ?? 'GET'
35
35
  if (method !== 'GET' && method !== 'HEAD') { json(res, 403, { error: 'share-read-only' }); return }
36
36
  const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
37
- const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
37
+ const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|trees|feed|annotations)$/u.exec(url.pathname)
38
38
  const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
39
39
  const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only|comparison)$/u.exec(url.pathname)
40
40
  if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
@@ -84,7 +84,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
84
84
  await send(await loadTeamAnnotations(deps.db, deps.reportStore, snapshot, url.searchParams.get('reportId'))); return
85
85
  }
86
86
  if (teamRoute[2] === 'shared') { await sendTeam(); return }
87
- const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
87
+ const body = await (teamRoute[2] === 'trees' ? loadTeamTreesResponse : loadTeamReportsResponse)(deps.db, deps.reportStore, snapshot)
88
88
  await recheck()
89
89
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
90
90
  writeResponse(res, body); return
@@ -132,7 +132,7 @@ function counted(answer: CommentReview[], read: Authored[], reviewers: Map<strin
132
132
  const { name, version, integrity, patchHash, status } = dependency ?? {}
133
133
  if (typeof name !== 'string' || typeof version !== 'string' || typeof integrity !== 'string' || !VERDICTS.has(status)) return []
134
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 }))
135
+ basePackage: null, baseVersion: null, reviewer, login: review.user as string, status: status as ReviewVerdict, full: true, time }))
136
136
  })
137
137
  })
138
138
  }
@@ -22,7 +22,10 @@
22
22
  // changes, so what is told of one is kept by them (`kept`), and each
23
23
  // lockfile read is kept in this instance's memory by its commit
24
24
  // (`lockfileReads`): the pull requests branching off the same commit, as a
25
- // bot's do, read their base's once.
25
+ // bot's do, read their base's once. One read as a commit lists it, with its
26
+ // blob's id (commit-lockfiles.ts), is kept by its blob instead, and beyond
27
+ // this instance where its reader keeps it (LockfileStore): what a blob holds
28
+ // never changes either, and a push changing no lockfile changes none.
26
29
  import { createClient } from '@preventive/upstream/github.js'
27
30
  import { GithubApiError, githubJson } from './github-app.ts'
28
31
  import { upstreamGithub } from './github-errors.ts'
@@ -46,7 +49,8 @@ export const MAX_LOCKED = 20_000
46
49
  // Lockfiles read at once.
47
50
  const READ_PARALLEL = 4
48
51
  // The rules what is told was told by, kept with it (http.ts
49
- // pullDependencyKept): one told by others is told again.
52
+ // pullDependencyKept), and the lockfiles read for it (lockfileKept): one
53
+ // told or read by others is told or read again.
50
54
  export const PULL_DEPENDENCIES_READ = 3
51
55
  const SHA = /^[a-f\d]{40}$/u
52
56
 
@@ -104,20 +108,42 @@ export interface PullDependencyStore {
104
108
  write(key: string, value: PullDependencies): Promise<void>
105
109
  }
106
110
 
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
+ // A lockfile's packages, or why its reader refused it, by its repository and
112
+ // readKey, read in this instance; by the packages each holds, as the scan's
113
+ // are (repository-risk.ts riskCaches).
114
+ export type LockfileRead = { packages: LockedPackage[] } | { error: string; detail?: string }
111
115
  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>>()
116
+ // Those being read, by the token reading them too, lest each asking at once
117
+ // read them, with whether GitHub was asked.
118
+ const lockfilesReading = new Map<string, Promise<{ read: LockfileRead; github: boolean }>>()
119
+ // A lockfile at a commit as findRepoLockfiles lists it, by its path, with its
120
+ // format (lockfileFormat) and its blob's id (`sha`).
121
+ export interface ListedLockfile { path: string; format: Format; sha: string }
122
+ // What a repository's lockfiles read for its dependency review keep beyond
123
+ // this instance (http.ts lockfileKept), by what never changes: a commit's
124
+ // lockfiles as listed (`listings`, by the commit), and a lockfile's read
125
+ // (`lockfiles`, by its readKey). A read GitHub failed is kept nowhere.
126
+ export interface LockfileStore {
127
+ listings: { read(commit: string): Promise<ListedLockfile[] | null>; write(commit: string, listed: ListedLockfile[]): Promise<void> }
128
+ lockfiles: { read(key: string): Promise<LockfileRead | null>; write(key: string, read: LockfileRead): Promise<void> }
129
+ }
130
+ // Set where GitHub was asked for what a read reads (`github`), not all of it
131
+ // held in this instance or kept beyond it.
132
+ export interface GithubAsked { github: boolean }
133
+
134
+ // A lockfile's read, by its blob where it is listed with its id (`sha`),
135
+ // which with its format alone tell how it reads; else by the commit and its
136
+ // path.
137
+ const readKey = (ref: string, { path, format, sha }: { path: string; format: Format; sha?: string }) =>
138
+ sha ? `blob\0${format}\0${sha}` : `commit\0${ref}\0${path}`
114
139
 
115
140
  const api = (fullName: string) => `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
116
141
 
117
142
  // The pull request's base's and head's commits, as GitHub has them now: its
118
143
  // base's moving on as it does, its head's with each push, both kept once it
119
144
  // is merged or closed.
120
- async function pullCommits(fullName: string, token: string, number: number, fetchImpl: typeof fetch) {
145
+ export interface PullCommits { base: string; head: string }
146
+ async function pullCommits(fullName: string, token: string, number: number, fetchImpl: typeof fetch): Promise<PullCommits> {
121
147
  const pull = await githubJson(`${api(fullName)}/pulls/${number}`, token, fetchImpl) as
122
148
  { number?: unknown; base?: { sha?: unknown; repo?: { full_name?: unknown } | null }; head?: { sha?: unknown } } | null
123
149
  const base = pull?.base?.sha, head = pull?.head?.sha, repo = pull?.base?.repo?.full_name
@@ -129,13 +155,16 @@ async function pullCommits(fullName: string, token: string, number: number, fetc
129
155
  // The pull request's merge base and the files it changes from it: as GitHub
130
156
  // compares its base with its head, else, where that lists as many as it lists
131
157
  // at most, from the pull request's own list, a page at a time; `cut` where
132
- // that lists as many as it lists at most, too.
133
- async function pullFiles(fullName: string, token: string, number: number, base: string, head: string, fetchImpl: typeof fetch) {
158
+ // that lists as many as it lists at most, too. That list is of its commits
159
+ // now: null where they are no longer `base` and `head` (`current`).
160
+ async function pullFiles(fullName: string, token: string, number: number, base: string, head: string, fetchImpl: typeof fetch,
161
+ current: () => Promise<boolean>) {
134
162
  const compared = await githubJson(`${api(fullName)}/compare/${base}...${head}?per_page=1`, token, fetchImpl) as { merge_base_commit?: { sha?: unknown } } | null
135
163
  const mergeBase = compared?.merge_base_commit?.sha
136
164
  if (typeof mergeBase !== 'string' || !SHA.test(mergeBase)) throw new GithubApiError(502, 'github-malformed')
137
165
  const { files, more } = commitChanges(compared)
138
166
  if (!more) return { mergeBase, files, cut: false }
167
+ if (!await current()) return null
139
168
  const listed: ChangedFile[] = []
140
169
  for (let page = 1; listed.length < PULL_FILES; page++) {
141
170
  const data = await githubJson(`${api(fullName)}/pulls/${number}/files?per_page=${FILES_PAGE}&page=${page}`, token, fetchImpl)
@@ -171,39 +200,49 @@ function lockfilesChanged(files: ChangedFile[]) {
171
200
  omitted: all.slice(MAX_DIRECTORIES).flatMap(([, directory]) => [...directory.paths]).toSorted() }
172
201
  }
173
202
 
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}`
186
- const known = lockfileReads.get(key)
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))
203
+ // A lockfile's packages at `ref`, as read in this instance, else as kept
204
+ // beyond it (`kept`), else shared with a read of it with the same token still
205
+ // under way, else of GitHub, and kept; a lockfile its reader refuses, why,
206
+ // kept as well, its reader refusing it again; `asked` told where GitHub was.
207
+ // Any failure to read it of GitHub but its having none there is the whole
208
+ // answer's, as it would be every file's; neither is kept.
209
+ export async function readLockfile(fullName: string, token: string, ref: string, lockfile: { path: string; format: Format; sha?: string },
210
+ kept: LockfileStore | null = null, asked?: GithubAsked): Promise<LockfileRead> {
211
+ if (lockfile.format === 'bun') return { error: 'lockfile-unsupported' }
212
+ const key = readKey(ref, lockfile)
213
+ const known = lockfileReads.get(`${fullName}\0${key}`)
214
+ if (known) return known
215
+ const shared = `${token}\0${fullName}\0${key}`
216
+ const reading = lockfilesReading.get(shared) ?? readOf(fullName, token, ref, lockfile, key, kept).finally(() => lockfilesReading.delete(shared))
190
217
  lockfilesReading.set(shared, reading)
191
- return reading
218
+ // One failing was asked of GitHub too, though a caller swallows its
219
+ // failure, as a baseline's does: its access is asked again all the same.
220
+ let told
221
+ try { told = await reading } catch (err) {
222
+ if (asked) asked.github = true
223
+ throw err
224
+ }
225
+ if (told.github && asked) asked.github = true
226
+ return told.read
192
227
  }
193
- async function readOf(fullName: string, token: string, ref: string, { path, format }: { path: string; format: Format }, key: string): Promise<LockfileRead> {
228
+ async function readOf(fullName: string, token: string, ref: string, { path, format }: { path: string; format: Format }, key: string,
229
+ kept: LockfileStore | null): Promise<{ read: LockfileRead; github: boolean }> {
230
+ const stored = await kept?.lockfiles.read(key)
231
+ if (stored) { lockfileReads.set(`${fullName}\0${key}`, stored); return { read: stored, github: false } }
194
232
  let text: string
195
233
  try {
196
234
  text = await upstreamGithub(() => createClient({ token, userAgent: 'deepview-triage' }).getRepoFile({ repo: fullName, path, ref }))
197
235
  } catch (err) {
198
- if (err instanceof GithubApiError && err.message === 'github-not-found') return { error: 'github-not-found' }
236
+ if (err instanceof GithubApiError && err.message === 'github-not-found') return { read: { error: 'github-not-found' }, github: true }
199
237
  throw err
200
238
  }
201
239
  let read: LockfileRead
202
240
  try { read = { packages: lockedPackages(format, text, { review: true }) } } catch (error) {
203
241
  read = { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
204
242
  }
205
- lockfileReads.set(key, read)
206
- return read
243
+ lockfileReads.set(`${fullName}\0${key}`, read)
244
+ await kept?.lockfiles.write(key, read)
245
+ return { read, github: true }
207
246
  }
208
247
 
209
248
  // Runs `run` for each of `items`, as many at once as `parallel`, each's
@@ -278,14 +317,23 @@ export async function pullDependencies(fullName: string, token: string, number:
278
317
  return (await pullDependenciesAt(fullName, token, number, kept, fetchImpl)).told
279
318
  }
280
319
  // The same, with the commits it is kept by (`commits`), its base's and
281
- // head's, by which `kept` reads it.
320
+ // head's, by which `kept` reads it: those `given`, as a page of its
321
+ // repository's open pull requests read them (repository-issues.ts), else as
322
+ // GitHub has them now. Commits given that its own list of its files is no
323
+ // longer of are read as GitHub has them now.
282
324
  export async function pullDependenciesAt(fullName: string, token: string, number: number, kept: PullDependencyStore,
283
- fetchImpl: typeof fetch = globalThis.fetch): Promise<{ commits: string; told: PullDependencies }> {
284
- const { base, head } = await pullCommits(fullName, token, number, fetchImpl)
325
+ fetchImpl: typeof fetch = globalThis.fetch, given?: PullCommits): Promise<{ commits: string; told: PullDependencies }> {
326
+ const { base, head } = given ?? await pullCommits(fullName, token, number, fetchImpl)
285
327
  const key = `${base}...${head}`
286
328
  const held = await kept.read(key)
287
329
  if (held) return { commits: key, told: held }
288
- const { mergeBase, files, cut: filesCut } = await pullFiles(fullName, token, number, base, head, fetchImpl)
330
+ const current = async () => {
331
+ const now = given && await pullCommits(fullName, token, number, fetchImpl)
332
+ return !now || (now.base === base && now.head === head)
333
+ }
334
+ const pulled = await pullFiles(fullName, token, number, base, head, fetchImpl, current)
335
+ if (!pulled) return pullDependenciesAt(fullName, token, number, kept, fetchImpl)
336
+ const { mergeBase, files, cut: filesCut } = pulled
289
337
  const { directories, cut, omitted } = lockfilesChanged(files)
290
338
  const reads = directories.flatMap(({ before, after }) => [...before.map(lockfile => [mergeBase, lockfile] as const), ...after.map(lockfile => [head, lockfile] as const)])
291
339
  const read = await eachOf(reads, READ_PARALLEL, ([ref, lockfile]) => readLockfile(fullName, token, ref, lockfile))
@@ -24,12 +24,12 @@
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 { type GithubAsked, lockedAt, lockedIn } from './commit-lockfiles.ts'
27
+ import { 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'
31
31
  import { type Lru, lru } from './lru.ts'
32
- import { type DirectoryDependencies, MAX_ADDED, type PullDependencies, lockedAs, lockedWith } from './pull-dependencies.ts'
32
+ import { type DirectoryDependencies, type GithubAsked, type LockfileStore, MAX_ADDED, type PullDependencies, lockedAs, lockedWith } from './pull-dependencies.ts'
33
33
  import type { PublishedReview, RepoReviewState } from './repository-dependency-reviews.ts'
34
34
  import { type ReviewedDependencies, type ReviewedDependency, askReviewed } from './reviewed-dependencies.ts'
35
35
 
@@ -64,17 +64,19 @@ const hashesOf = (pkg: LockedPackage) => (pkg.integrity ?? '').split(/\s+/u).fil
64
64
  const baselineKeys = (pkg: LockedPackage) => hashesOf(pkg).map(hash => `${pkg.name}@${pkg.version ?? ''}\0${hash}\0${pkg.patchHash ?? ''}`)
65
65
 
66
66
  // The versions the repository's lockfiles locked at commit `since` that are
67
- // reviewed (reviewedAs), as kept, else read of GitHub (commit-lockfiles.ts)
68
- // and kept; with the lockfiles there its reader refuses, isn't one of, or
69
- // past those read (`unread`). GitHub having none of a lockfile it lists
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, asked?: GithubAsked): Promise<Baseline> {
67
+ // reviewed (reviewedAs), as kept, else read of GitHub (commit-lockfiles.ts,
68
+ // its lockfiles as `reads` keeps them) and kept; with the lockfiles there its
69
+ // reader refuses, isn't one of, or past those read (`unread`). GitHub having
70
+ // none of a lockfile it lists there is a failure of the whole, as of any
71
+ // file, never kept.
72
+ export async function reviewBaseline(fullName: string, token: string, repoId: number, since: string, kept: ReviewBaselineStore, reads: LockfileStore | null = null,
73
+ asked?: GithubAsked): Promise<Baseline> {
72
74
  const key = `${repoId}\0${since}`
73
75
  const known = baselines.get(key)
74
76
  if (known) return known
75
77
  const stored = await kept.read(key)
76
78
  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, () => true, asked)
79
+ const read = await lockedAt(fullName, token, since, reads, () => true, asked)
78
80
  const { packages } = lockedIn(read), { unread } = read
79
81
  const versions = new Set<string>()
80
82
  for (const pkg of packages) if (reviewedAs(pkg) === 'version') for (const held of baselineKeys(pkg)) versions.add(held)
@@ -105,7 +107,9 @@ const ACCEPTING = new Set(['accept', 'accept-with-nits'])
105
107
  // review of theirs, whole or compared with one of `bases` of its package
106
108
  // (any, for `any`); and how many review it in its pull request's comments
107
109
  // alone (`commented`), which count for it alone: whose reviews passing it,
108
- // else whose latest, are its comments'.
110
+ // else whose latest, are its comments'. Each reviewer too (`reviewers`), by
111
+ // their login, their latest verdict, whether they pass it, and whether that
112
+ // verdict is its comments' (`comment`), which counts for it alone.
109
113
  function stancesOf(pkg: LockedPackage, reviews: ReviewsOf, bases: string[] | 'any') {
110
114
  const hashes = hashesOf(pkg)
111
115
  const by = new Map<string, PublishedReview[]>()
@@ -116,17 +120,21 @@ function stancesOf(pkg: LockedPackage, reviews: ReviewsOf, bases: string[] | 'an
116
120
  const qualifies = (review: PublishedReview) => review.full && ACCEPTING.has(review.status)
117
121
  && (review.basePackage === null || (review.basePackage === pkg.name && (bases === 'any' || bases.includes(review.baseVersion ?? ''))))
118
122
  let commented = 0, passing = 0, rejected = false
123
+ const reviewers: Reviewer[] = []
119
124
  for (const theirs of by.values()) {
120
125
  const latest = theirs.reduce((one, other) => other.time > one.time ? other : one)
126
+ const passes = ACCEPTING.has(latest.status) && theirs.some(qualifies)
121
127
  // In its comments alone where those telling where they stand are: those
122
128
  // passing it, where they pass it, else their latest.
123
- const telling = ACCEPTING.has(latest.status) && theirs.some(qualifies) ? theirs.filter(qualifies) : [latest]
124
- if (telling.every(review => review.comment)) commented++
129
+ const comment = (passes ? theirs.filter(qualifies) : [latest]).every(review => review.comment)
130
+ if (comment) commented++
125
131
  if (latest.status === 'reject') rejected = true
126
- else if (ACCEPTING.has(latest.status) && theirs.some(qualifies)) passing++
132
+ else if (passes) passing++
133
+ reviewers.push({ login: latest.login, status: latest.status, ...passes ? { passes: true as const } : {}, ...latest.comment ? { comment: true as const } : {} })
127
134
  }
128
- return { passing, rejected, commented }
135
+ return { passing, rejected, commented, reviewers }
129
136
  }
137
+ type Reviewer = { login: string; status: PublishedReview['status']; passes?: true; comment?: true }
130
138
 
131
139
  // A version as the client is told it: what its reviews are held to left
132
140
  // out, but whether it is patched.
@@ -146,7 +154,8 @@ function askedOf(pkg: LockedPackage): [string, ReviewedDependency] {
146
154
 
147
155
  // A version's review, and whether it wants one (`wants`), neither passing
148
156
  // nor needing none, as only one that can be reviewed may.
149
- type Review = { unsupported: Unsupported } | { baseline: true } | { reviewed: true } | { reviews: number; rejected?: true; commented?: number } | Record<string, never>
157
+ type Review = { unsupported: Unsupported } | { baseline: true } | { reviewed: true } | { reviews: number; rejected?: true; commented?: number; reviewers?: Reviewer[] }
158
+ | Record<string, never>
150
159
  interface Judged { review: Review; wants: boolean }
151
160
  type Judge = (pkg: LockedPackage, bases: string[] | 'any') => Judged
152
161
  interface Context { state: RepoReviewState; reviews: ReviewsOf; baseline: Set<string> | null }
@@ -155,8 +164,8 @@ function judge(pkg: LockedPackage, bases: string[] | 'any', { state, reviews, ba
155
164
  if (as === 'skip') return { review: {}, wants: false }
156
165
  if (as !== 'version') return { review: { unsupported: as }, wants: false }
157
166
  if (baselineKeys(pkg).some(key => baseline?.has(key))) return { review: { baseline: true }, wants: false }
158
- const { passing, rejected, commented } = stancesOf(pkg, reviews, bases)
159
- const told = { reviews: passing, ...commented > 0 ? { commented } : {} }
167
+ const { passing, rejected, commented, reviewers } = stancesOf(pkg, reviews, bases)
168
+ const told = { reviews: passing, ...commented > 0 ? { commented } : {}, ...reviewers.length > 0 ? { reviewers } : {} }
160
169
  if (rejected) return { review: { ...told, rejected: true }, wants: true }
161
170
  return { review: told, wants: passing < state.required }
162
171
  }
@@ -306,10 +315,11 @@ export const reviewedPackages = (told: PullDependencies) => told.lockfiles
306
315
  // A repository's baseline as read for a pull request: its versions and the
307
316
  // lockfiles there left unread; none where it reviews since no commit; and
308
317
  // where GitHub doesn't read it, none and why.
309
- export async function baselineFor(fullName: string, token: string, repoId: number, state: RepoReviewState, kept: ReviewBaselineStore, asked?: GithubAsked) {
318
+ export async function baselineFor(fullName: string, token: string, repoId: number, state: RepoReviewState, kept: ReviewBaselineStore, reads: LockfileStore | null,
319
+ asked?: GithubAsked) {
310
320
  if (!state.since) return { baseline: null, unread: [], error: null }
311
321
  try {
312
- const { versions, unread } = await reviewBaseline(fullName, token, repoId, state.since, kept, asked)
322
+ const { versions, unread } = await reviewBaseline(fullName, token, repoId, state.since, kept, reads, asked)
313
323
  return { baseline: versions, unread, error: null }
314
324
  } catch (err) {
315
325
  if (err instanceof GithubApiError && err.status !== 429 && err.status !== 401) return { baseline: null, unread: [], error: err.message }