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

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 (108) 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 -12
  4. package/common/managed/github-agents.ts +29 -10
  5. package/out/activity.js +4 -0
  6. package/out/chunk-6LNQTKAV.js +52 -0
  7. package/out/chunk-7BKKYVWR.js +7 -0
  8. package/out/chunk-7VAWINQF.js +6 -0
  9. package/out/chunk-C2AVODGZ.js +36 -0
  10. package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
  11. package/out/chunk-MOACFRJ4.js +3 -0
  12. package/out/chunk-O6M574JI.js +1 -0
  13. package/out/chunk-PBZPYIG7.js +1 -0
  14. package/out/chunk-PENGGKX7.js +233 -0
  15. package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
  16. package/out/chunk-SZD5CW5R.js +3 -0
  17. package/out/{chunk-G24A42V6.js → chunk-TQJDYHVE.js} +1 -1
  18. package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
  19. package/out/{chunk-7XRR4OFU.js → chunk-WYLQWYLA.js} +1 -1
  20. package/out/chunk-X6PJAK3T.js +1 -0
  21. package/out/client-managed-import.js +1 -1
  22. package/out/client-managed.js +26 -26
  23. package/out/client-sync.js +2 -2
  24. package/out/compare.js +14 -14
  25. package/out/dialogs.js +40 -40
  26. package/out/graph.js +11 -11
  27. package/out/index.html +12 -12
  28. package/out/scan.js +2 -2
  29. package/out/terminal.js +215 -215
  30. package/out/view.css +1 -1
  31. package/out/view.js +15 -15
  32. package/package.json +9 -5
  33. package/risk/index.d.ts +4 -2
  34. package/risk/npm/assess.js +15 -4
  35. package/risk/npm/registry.js +6 -1
  36. package/risk/npm/score.js +1 -1
  37. package/server-managed/activity.ts +24 -11
  38. package/server-managed/agent-merges.ts +10 -8
  39. package/server-managed/app-agents.ts +8 -21
  40. package/server-managed/bundle-build-worker.js +7 -3
  41. package/server-managed/bundle-build.ts +5 -3
  42. package/server-managed/bundle-cache.ts +9 -3
  43. package/server-managed/bundle.ts +3 -4
  44. package/server-managed/cache-storage.ts +3 -2
  45. package/server-managed/commit-coauthors.ts +11 -10
  46. package/server-managed/commit-lockfiles.ts +22 -21
  47. package/server-managed/db-methods.ts +338 -187
  48. package/server-managed/db-neon.ts +31 -8
  49. package/server-managed/db.ts +2 -0
  50. package/server-managed/dependabot-access.ts +1 -1
  51. package/server-managed/dependency-review-comments.ts +7 -4
  52. package/server-managed/dependency-reviews.ts +71 -33
  53. package/server-managed/github-accounts.ts +28 -1
  54. package/server-managed/github-app.ts +27 -23
  55. package/server-managed/github-graphql.ts +39 -0
  56. package/server-managed/github-issue-links.ts +8 -1
  57. package/server-managed/github-metadata.ts +2 -2
  58. package/server-managed/http-response.ts +28 -10
  59. package/server-managed/http.ts +736 -247
  60. package/server-managed/index.ts +2 -2
  61. package/server-managed/link-reports.ts +5 -1
  62. package/server-managed/managed-issues.ts +3 -0
  63. package/server-managed/npm-delta.ts +16 -0
  64. package/server-managed/npm-kept.ts +51 -10
  65. package/server-managed/npm-loads.ts +48 -28
  66. package/server-managed/npm-manifest.ts +1 -1
  67. package/server-managed/npm-packages.ts +31 -15
  68. package/server-managed/npm-risk.ts +64 -17
  69. package/server-managed/object-storage.ts +4 -3
  70. package/server-managed/pretty-print.ts +45 -12
  71. package/server-managed/public-workspace.ts +3 -3
  72. package/server-managed/pull-dependencies.ts +84 -36
  73. package/server-managed/pull-dependency-reviews.ts +13 -10
  74. package/server-managed/rename-diff-worker.js +1 -1
  75. package/server-managed/report-store.ts +16 -11
  76. package/server-managed/repository-agents.ts +9 -6
  77. package/server-managed/repository-browser.ts +17 -5
  78. package/server-managed/repository-code.ts +180 -17
  79. package/server-managed/repository-dependency-reviews.ts +7 -0
  80. package/server-managed/repository-history.ts +85 -152
  81. package/server-managed/repository-issues.ts +27 -13
  82. package/server-managed/repository-pulse.ts +9 -3
  83. package/server-managed/repository-squashes.ts +145 -0
  84. package/server-managed/request-timing.ts +63 -0
  85. package/server-managed/reviewed-dependencies.ts +3 -2
  86. package/server-managed/session.ts +34 -12
  87. package/server-managed/solidity-entry-points.ts +5 -3
  88. package/server-managed/sql-postgres.ts +3 -0
  89. package/server-managed/sql.ts +17 -8
  90. package/server-managed/storage-db.ts +11 -3
  91. package/server-managed/storage-encryption.ts +23 -12
  92. package/server-managed/storage-maintenance.ts +5 -2
  93. package/server-managed/storage-payload.ts +18 -0
  94. package/server-managed/storage-stores.ts +3 -2
  95. package/server-managed/storage.ts +4 -2
  96. package/server-managed/team-feed.ts +19 -12
  97. package/server-managed/team-npm-scopes.ts +29 -0
  98. package/server-managed/team-reports.ts +74 -17
  99. package/server-managed/uploads.ts +29 -9
  100. package/out/chunk-4UFAMJAX.js +0 -1
  101. package/out/chunk-BOIXMUU5.js +0 -3
  102. package/out/chunk-CC4OM6TZ.js +0 -3
  103. package/out/chunk-JKGQ3O7O.js +0 -6
  104. package/out/chunk-LE5ZWLG4.js +0 -235
  105. package/out/chunk-MSION3G4.js +0 -36
  106. package/out/chunk-S6GIXWQW.js +0 -52
  107. package/out/chunk-V3KWNNMY.js +0 -1
  108. package/out/chunk-WQ74ZBZF.js +0 -7
@@ -45,6 +45,10 @@ const MAX_KEPT_DETAILS_BYTES = 256 * 1024 * 1024
45
45
  // Files hashed at once: one at a time, each waits its own round trip to the
46
46
  // thread pool; all at once, every one's job is held together.
47
47
  const HASHES_AT_ONCE = 32
48
+ // Kept copies a stream reads ahead of the line it is sending (readAhead):
49
+ // one at a time, each waits its own storage round trip; each read whole, as
50
+ // its line will send it.
51
+ const KEPT_AHEAD = 8
48
52
 
49
53
  export class PrettyError extends Error {
50
54
  status: number
@@ -166,6 +170,29 @@ async function prettyLine({ path, hash }: PrettyRequest, copy: () => Promise<Ope
166
170
  }
167
171
  }
168
172
 
173
+ // A file's line from its kept copy, `open` opening it, or null where none is
174
+ // kept.
175
+ async function keptLine(request: PrettyRequest, open: () => Promise<OpenedBlob | null>): Promise<PrettyLine | null> {
176
+ const copy = await open()
177
+ return copy && prettyLine(request, () => Promise.resolve(copy))
178
+ }
179
+
180
+ // Each of `items` with what `read` reads of it, in their order, up to
181
+ // KEPT_AHEAD of them read at once ahead of the one iterated, each answered
182
+ // (or failing) in its turn. One iterated no further starts no more; those
183
+ // under way settle unread.
184
+ async function* readAhead<T, R>(items: readonly T[], read: (item: T) => Promise<R>): AsyncGenerator<[T, R]> {
185
+ const reads: Promise<R>[] = []
186
+ for (const [i, item] of items.entries()) {
187
+ while (reads.length < Math.min(items.length, i + KEPT_AHEAD)) {
188
+ const reading = read(items[reads.length]!)
189
+ reading.catch(() => {})
190
+ reads.push(reading)
191
+ }
192
+ yield [item, await reads[i]!]
193
+ }
194
+ }
195
+
169
196
  // The files that changed between two sides, pretty-printed: each side's
170
197
  // formattable files the other has another text at the path of, or, where it
171
198
  // has none there, none of their text at a path of its own (a file renamed, or
@@ -174,8 +201,9 @@ async function prettyLine({ path, hash }: PrettyRequest, copy: () => Promise<Ope
174
201
  // the viewer diffs but where it pairs files otherwise (among copies of one
175
202
  // text, across packages), whose copies it asks for alone. As they are
176
203
  // iterated, in path order, the base's first where both have one: those kept,
177
- // at once; then the rest, formatted one after another, the minified before
178
- // the others (needsPrettyPrint), as the viewer prints those unasked.
204
+ // at once, a few read ahead (readAhead); then the rest, formatted one after
205
+ // another, the minified before the others (needsPrettyPrint), as the viewer
206
+ // prints those unasked.
179
207
  export function compared(sides: readonly [ComparedSide, ComparedSide]): AsyncGenerator<ComparedLine> {
180
208
  // Each side's texts at the paths the other hasn't: where a file moved has its own.
181
209
  const moved = sides.map((own, i) => new Set([...own.hashes].filter(([path]) => !sides[1 - i]!.hashes.has(path)).map(([, hash]) => hash)))
@@ -185,9 +213,8 @@ export function compared(sides: readonly [ComparedSide, ComparedSide]): AsyncGen
185
213
  }).toSorted((a, b) => (a.file.path < b.file.path ? -1 : a.file.path > b.file.path ? 1 : 0))
186
214
  return (async function* () {
187
215
  const unkept: typeof changed = []
188
- for (const { own, file } of changed) {
189
- const copy = await own.kept(file)
190
- if (copy) yield { side: own.side, ...await prettyLine(file, () => Promise.resolve(copy)) }
216
+ for await (const [{ own, file }, line] of readAhead(changed, item => keptLine(item.file, () => item.own.kept(item.file)))) {
217
+ if (line) yield { side: own.side, ...line }
191
218
  else unkept.push({ own, file })
192
219
  }
193
220
  const minified = new Map(unkept.map(({ file }) => [file, needsPrettyPrint(file.path, file.text)]))
@@ -325,15 +352,18 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
325
352
  return body
326
353
  })
327
354
  }
355
+ // The copy kept for a public version's file, or null where none is (and
356
+ // for a private version's, which none is kept for).
357
+ const npmKept = (doc: NpmVersionDocument, request: PrettyRequest) => (doc.private ? Promise.resolve(null) : kept(() => npm.open(prettyFile(request))))
328
358
  // A version's file pretty-printed, `read` reading its text (and checking
329
359
  // it is the file named): from the copy kept for a public version, else
330
360
  // made, and kept for a public one.
331
361
  async function npmCopy(doc: NpmVersionDocument, request: PrettyRequest, read: () => Promise<string>): Promise<OpenedBlob> {
362
+ return await npmKept(doc, request) ?? npmMade(doc, request, read)
363
+ }
364
+ // The file pretty-printed from the text `read` reads, kept for a public version.
365
+ async function npmMade(doc: NpmVersionDocument, request: PrettyRequest, read: () => Promise<string>): Promise<OpenedBlob> {
332
366
  const file = prettyFile(request)
333
- if (!doc.private) {
334
- try { return await npm.open(file) }
335
- catch (err) { if (!(err instanceof CacheMissError)) throw err }
336
- }
337
367
  return opened(await shared(doc.private ? `npm ${doc.dist.integrity} ${file}` : `npm ${file}`, async () => {
338
368
  const body = await prettyBody(await read(), request.extension)
339
369
  if (!doc.private) await keep(() => npm.put(file, body))
@@ -369,11 +399,14 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
369
399
  },
370
400
  // Every file of the version that needs pretty-printing (npmPrintable),
371
401
  // read before this resolves; then, as they are iterated, each
372
- // pretty-printed as `npm` has it, one after another.
402
+ // pretty-printed as `npm` has it, one after another, those kept a few
403
+ // read ahead (readAhead).
373
404
  async npmAll(doc: NpmVersionDocument): Promise<AsyncGenerator<PrettyLine>> {
374
405
  const files = await npmPrintable(doc)
375
406
  return (async function* () {
376
- for (const { text, ...request } of files) yield await prettyLine(request, () => npmCopy(doc, request, () => Promise.resolve(text)))
407
+ for await (const [file, line] of readAhead(files, request => keptLine(request, () => npmKept(doc, request)))) {
408
+ yield line ?? await prettyLine(file, () => npmMade(doc, file, () => Promise.resolve(file.text)))
409
+ }
377
410
  })()
378
411
  },
379
412
  // Two bundles' sides, or two versions', as a comparison reads them, read
@@ -392,7 +425,7 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
392
425
  npmSides(base: NpmVersionDocument, other: NpmVersionDocument): Promise<[ComparedSide, ComparedSide]> {
393
426
  const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc), (_, bytes) => npmFileHash(bytes)),
394
427
  packageDirs: null,
395
- kept: request => (doc.private ? Promise.resolve(null) : kept(() => npm.open(prettyFile(request)))),
428
+ kept: request => npmKept(doc, request),
396
429
  made: (request, text) => npmCopy(doc, request, () => Promise.resolve(text)) })
397
430
  return Promise.all([sideOf(base, 'base'), sideOf(other, 'other')])
398
431
  },
@@ -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
@@ -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)
@@ -306,10 +308,11 @@ export const reviewedPackages = (told: PullDependencies) => told.lockfiles
306
308
  // A repository's baseline as read for a pull request: its versions and the
307
309
  // lockfiles there left unread; none where it reviews since no commit; and
308
310
  // 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) {
311
+ export async function baselineFor(fullName: string, token: string, repoId: number, state: RepoReviewState, kept: ReviewBaselineStore, reads: LockfileStore | null,
312
+ asked?: GithubAsked) {
310
313
  if (!state.since) return { baseline: null, unread: [], error: null }
311
314
  try {
312
- const { versions, unread } = await reviewBaseline(fullName, token, repoId, state.since, kept, asked)
315
+ const { versions, unread } = await reviewBaseline(fullName, token, repoId, state.since, kept, reads, asked)
313
316
  return { baseline: versions, unread, error: null }
314
317
  } catch (err) {
315
318
  if (err instanceof GithubApiError && err.status !== 429 && err.status !== 401) return { baseline: null, unread: [], error: err.message }
@@ -1,6 +1,6 @@
1
1
  // Compare's diff with renamed names hidden (rename-diff.ts), worked out off the
2
2
  // server's event loop by @preventive/compare's renamedDiffs: the old side given
3
- // the new side's names, formatted again where that changes its layout alone,
3
+ // the new side's names, not formatted again (each line where the old side's was),
4
4
  // diffed with whitespace changes shown and hidden, and the blocks checked to
5
5
  // take it to the new side. `{ before, blocks, reason, reads, marks,
6
6
  // ignoringWhitespace }`: the old side as diffed, the change blocks, why the
@@ -32,9 +32,7 @@ export function createReportStore(legacy: BlobStore, compressed: BlobStore,
32
32
  if (compressedOnly.size >= COMPRESSED_ONLY_MAX) compressedOnly.delete(compressedOnly.values().next().value!)
33
33
  compressedOnly.add(id)
34
34
  }
35
- async function openCompressed(id: string): Promise<OpenedBlob | null> {
36
- const stored = await compressed.open(id)
37
- if (!stored) return null
35
+ function decoded(stored: OpenedBlob): OpenedBlob {
38
36
  const stream = createBrotliDecompress()
39
37
  // pipeline carries storage/authentication errors into the decoded stream
40
38
  // and closes the stored source when a reader leaves before EOF.
@@ -43,22 +41,29 @@ export function createReportStore(legacy: BlobStore, compressed: BlobStore,
43
41
  }
44
42
  async function open(id: string): Promise<OpenedBlob | null> {
45
43
  if (compressedOnly.has(id)) {
46
- const stored = await openCompressed(id)
47
- if (stored) return stored
44
+ const stored = await compressed.open(id)
45
+ if (stored) return decoded(stored)
48
46
  compressedOnly.delete(id)
49
47
  }
50
48
  // Keep the original available until conversion succeeds. Concurrent reads
51
49
  // can finish using an already-open stream while another reader converts it.
52
- const original = await legacy.open(id)
53
- if (original) {
54
- const bytes = await readBytes(original)
50
+ // Both copies are asked for at once, the original answering where it is
51
+ // still there: one round trip of storage for a report not known yet, as
52
+ // a cold instance knows none.
53
+ const [original, stored] = await Promise.allSettled([legacy.open(id), compressed.open(id)])
54
+ const unused = (copy: PromiseSettledResult<OpenedBlob | null>) => { if (copy.status === 'fulfilled') copy.value?.stream.destroy() }
55
+ if (original.status === 'rejected') { unused(stored); throw original.reason }
56
+ if (original.value) {
57
+ unused(stored)
58
+ const bytes = await readBytes(original.value)
55
59
  await replaceLegacy(id, await encodeBrotli(bytes, 9))
56
60
  remember(id)
57
61
  return { size: bytes.length, stream: Readable.from([bytes]) }
58
62
  }
59
- const stored = await openCompressed(id)
60
- if (stored) remember(id)
61
- return stored
63
+ if (stored.status === 'rejected') throw stored.reason
64
+ if (!stored.value) return null
65
+ remember(id)
66
+ return decoded(stored.value)
62
67
  }
63
68
  return {
64
69
  async put(id, bytes) {
@@ -11,13 +11,12 @@
11
11
  // an agent, and those alone are answered, never the texts. GraphQL alone
12
12
  // gives them, so it needs a token.
13
13
  import { type AgentAuthor, type AgentBranch, type AgentCommit, type AgentIssue, type AgentPull, type AppAgent, agentSignals } from '../common/managed/agent-tasks.ts'
14
+ import { GITHUB_AGENTS, type GithubAgentAccount, type GithubAgentId } from '../common/managed/github-agents.ts'
14
15
  import { AGENT_BRANCH_PREFIXES, APP_AGENTS } from './app-agents.ts'
15
16
  import { GithubApiError } from './github-app.ts'
16
- import { ACTOR, type NamedAccount, actorOf, commitAccount, githubGraphql } from './repository-history.ts'
17
+ import { ACTOR, type NamedAccount, actorOf, commitAccount, githubGraphql, linkNoreplyUsers } from './repository-history.ts'
17
18
  import { ISSUES_CURSOR } from './repository-issues.ts'
18
19
 
19
- export { APP_AGENTS }
20
-
21
20
  export const AGENT_PULLS_PAGE = 50
22
21
  export const AGENT_COMMITS_PAGE = 100
23
22
  const AGENT_ISSUES = 100
@@ -35,7 +34,8 @@ const SHA = /^[a-f\d]{40}$/u
35
34
  const CLAUDE_EMAIL = 'noreply@anthropic.com'
36
35
 
37
36
  // The app agents' accounts' no-reply addresses, by each login of theirs.
38
- const AGENT_EMAILS = APP_AGENTS.flatMap(({ account }) => account?.logins.map(login => `${account.id}+${login}@users.noreply.github.com`) ?? [])
37
+ const AGENT_EMAILS = APP_AGENTS.flatMap(({ id }) => GITHUB_AGENTS[id as GithubAgentId].accounts
38
+ .flatMap(({ id: account, logins }: GithubAgentAccount) => account === undefined ? [] : logins.map(login => `${account}+${login}@users.noreply.github.com`)))
39
39
 
40
40
  // An agent's branches, a page at a time, as GitHub lists them, by name;
41
41
  // each its tip commit's time, message and author.
@@ -164,6 +164,7 @@ async function agentBranches(owner: string, name: string, prefix: string, token:
164
164
  for (let page = 0; page < MAX_BRANCH_PAGES; page++) {
165
165
  const refs: Connection<RefNode> | undefined = (await githubGraphql(BRANCHES_QUERY, { owner, name, prefix: `refs/heads/${prefix}`, after }, token, fetchImpl) as RefsPage)
166
166
  ?.repository?.refs
167
+ await linkNoreplyUsers((refs?.nodes ?? []).map(node => node?.target?.author), token, fetchImpl)
167
168
  found.push(...(refs?.nodes ?? []).flatMap(node => branch(prefix)(node) ?? []))
168
169
  const next: Connection<RefNode>['pageInfo'] = refs?.pageInfo
169
170
  if (next?.hasNextPage !== true || typeof next.endCursor !== 'string') break
@@ -175,7 +176,7 @@ async function agentBranches(owner: string, name: string, prefix: string, token:
175
176
  // What its answers are kept by, with their cursors (http.ts
176
177
  // handleRepositoryAgents): bumped as what a page reads, or how it tells an
177
178
  // agent, changes, so that no answer an older server kept is sent for a day.
178
- export const AGENTS_READ_VERSION = 5
179
+ export const AGENTS_READ_VERSION = 7
179
180
 
180
181
  // A cursor of the default branch's commits: GitHub names one by a commit
181
182
  // and how many on from it, `<oid> <offset>`, a space between.
@@ -206,7 +207,9 @@ export async function repositoryAgents(fullName: string, token: string, cursors:
206
207
  if (!repository) throw new GithubApiError(404, 'github-not-found')
207
208
  const history = repository.defaultBranchRef?.target?.history
208
209
  const after = { pulls: nextCursor(repository.pullRequests), commits: nextCursor(history, COMMITS_CURSOR) }
209
- const commits = [...history?.nodes ?? [], ...authored?.repository?.defaultBranchRef?.target?.history?.nodes ?? []].flatMap(node => directCommit(node) ?? [])
210
+ const nodes = [...history?.nodes ?? [], ...authored?.repository?.defaultBranchRef?.target?.history?.nodes ?? []]
211
+ await linkNoreplyUsers(nodes.map(node => node?.author), token, fetchImpl)
212
+ const commits = nodes.flatMap(node => directCommit(node) ?? [])
210
213
  return {
211
214
  pulls: (repository.pullRequests?.nodes ?? []).flatMap(node => pull(node) ?? []),
212
215
  commits: commits.filter((commit, index) => commits.findIndex(other => other.oid === commit.oid) === index),
@@ -171,9 +171,16 @@ async function askAccess(repo: SelectedRepo, start: string | null, userToken: st
171
171
  return githubJson(base, token, fetchImpl)
172
172
  }
173
173
  }
174
- // A repository last known private asks who the user is as it asks for its
175
- // metadata, not after: its permission is asked of them.
176
- if (repo.private && userToken) getIdentity().catch(() => {})
174
+ // A repository last known private asks who the user is, and mints the
175
+ // installation token its permission is asked with, as it asks for its
176
+ // metadata, not after. Each is asked once a request (the token once for
177
+ // concurrent requests too, github-app.ts), so a failure is answered where
178
+ // it is awaited. One made public since mints a token it never waits for,
179
+ // kept for later requests.
180
+ if (repo.private && userToken) {
181
+ getIdentity().catch(() => {})
182
+ if (repo.installationId != null) installationToken().catch(() => {})
183
+ }
177
184
  // Stored visibility can be stale (or internal with private=false). Require
178
185
  // current, explicit public visibility or the caller's effective permission.
179
186
  const data = await metadata() as RepoMetadata
@@ -232,6 +239,9 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
232
239
  // The user's permission level on it where the gate asked GitHub of it, as
233
240
  // for a private repository; null for a public one.
234
241
  permission: () => level,
242
+ // Its default branch as GitHub named it when access was first checked,
243
+ // `''` where it named none: the one whose head `commit('')` reads.
244
+ defaultBranch,
235
245
  async refs() {
236
246
  // Suggestions are bounded; the input also accepts any branch or tag name.
237
247
  const list = async (kind: string) => {
@@ -272,8 +282,10 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
272
282
  const manifestPath = path ? `${path}/package.json` : 'package.json'
273
283
  const manifest = data.find(item => item?.path === manifestPath && item.name === 'package.json' && item.type === 'file' && !item.submodule_git_url
274
284
  && typeof item.size === 'number' && item.size >= 0 && item.size <= MAX_PACKAGE_BYTES && typeof item.sha === 'string' && /^[a-f\d]{40}$/iu.test(item.sha))
275
- const packageEntryPoints = manifest ? await readPackageEntryPoints(manifest.sha, path, read) : undefined
276
- const solidity = await readSolidityEntryPoints(path, data, read)
285
+ // Both optional suggestions, read together: neither fails, each
286
+ // answering none where its reads do.
287
+ const [packageEntryPoints, solidity] = await Promise.all([manifest ? readPackageEntryPoints(manifest.sha, path, read) : undefined,
288
+ readSolidityEntryPoints(path, data, read)])
277
289
  return { entries, limited: data.length >= 1000, ...(packageEntryPoints ? { packageEntryPoints } : {}),
278
290
  ...(solidity.paths.length > 0 || solidity.limited ? { solidityEntryPoints: solidity.paths, soliditySuggestionsLimited: solidity.limited } : {}),
279
291
  }