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

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 (110) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +5 -1
  5. package/common/line-diff.js +5 -4
  6. package/common/managed/agent-tasks.ts +5 -3
  7. package/common/managed/github-agents.ts +28 -10
  8. package/out/activity.js +4 -0
  9. package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
  10. package/out/chunk-5AHIQFFG.js +36 -0
  11. package/out/chunk-6ANSHRFQ.js +6 -0
  12. package/out/chunk-6WF3KNTL.js +3 -0
  13. package/out/chunk-7BKKYVWR.js +7 -0
  14. package/out/{chunk-HMAJDVXN.js → chunk-FH24VHSJ.js} +1 -1
  15. package/out/chunk-NOZJULWS.js +52 -0
  16. package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
  17. package/out/chunk-S6U2J62U.js +232 -0
  18. package/out/chunk-SPEFPML6.js +2 -0
  19. package/out/chunk-UENXT2HK.js +3 -0
  20. package/out/chunk-VL43BTSO.js +1 -0
  21. package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
  22. package/out/chunk-ZCS4UYHI.js +1 -0
  23. package/out/chunk-ZGNMDGSB.js +1 -0
  24. package/out/client-managed-import.js +1 -1
  25. package/out/client-managed.js +15 -15
  26. package/out/client-sync.js +2 -2
  27. package/out/compare.js +12 -12
  28. package/out/dialogs.js +28 -28
  29. package/out/graph.js +11 -11
  30. package/out/index.html +11 -11
  31. package/out/scan.js +2 -2
  32. package/out/terminal.js +6 -6
  33. package/out/view.css +1 -1
  34. package/out/view.js +15 -15
  35. package/package.json +4 -1
  36. package/risk/actions/findings.js +14 -9
  37. package/risk/actions/scripts/shell.js +5 -1
  38. package/risk/index.d.ts +9 -4
  39. package/risk/npm/assess.js +15 -4
  40. package/risk/npm/registry.js +6 -1
  41. package/server-e2e/app.ts +5 -4
  42. package/server-e2e/http.ts +5 -2
  43. package/server-managed/activity.ts +24 -11
  44. package/server-managed/app-agents.ts +10 -21
  45. package/server-managed/bundle-build-worker.js +7 -3
  46. package/server-managed/bundle-build.ts +5 -3
  47. package/server-managed/bundle-cache.ts +16 -4
  48. package/server-managed/bundle.ts +3 -4
  49. package/server-managed/combined.ts +3 -2
  50. package/server-managed/commit-coauthors.ts +5 -9
  51. package/server-managed/commit-lockfiles.ts +21 -11
  52. package/server-managed/config.ts +6 -0
  53. package/server-managed/db-methods.ts +321 -156
  54. package/server-managed/db-neon.ts +71 -24
  55. package/server-managed/db-schema.ts +4 -0
  56. package/server-managed/db.ts +2 -1
  57. package/server-managed/dependency-reviews.ts +13 -3
  58. package/server-managed/github-app.ts +12 -6
  59. package/server-managed/github-issue-links.ts +11 -5
  60. package/server-managed/github-pulls.ts +2 -2
  61. package/server-managed/github-requests.ts +39 -0
  62. package/server-managed/http-response.ts +10 -2
  63. package/server-managed/http.ts +269 -143
  64. package/server-managed/index.ts +8 -7
  65. package/server-managed/kept-briefly.ts +37 -0
  66. package/server-managed/link-reports.ts +5 -1
  67. package/server-managed/npm-delta.ts +11 -6
  68. package/server-managed/npm-kept.ts +51 -7
  69. package/server-managed/npm-loads.ts +117 -35
  70. package/server-managed/npm-packages.ts +107 -14
  71. package/server-managed/npm-risk.ts +68 -18
  72. package/server-managed/pretty-print.ts +112 -35
  73. package/server-managed/pull-dependencies.ts +29 -9
  74. package/server-managed/pull-dependency-reviews.ts +8 -8
  75. package/server-managed/queue.ts +16 -0
  76. package/server-managed/rename-diff.ts +22 -13
  77. package/server-managed/report-sources.ts +1 -1
  78. package/server-managed/report-store.ts +50 -12
  79. package/server-managed/repository-actions.ts +15 -6
  80. package/server-managed/repository-agents.ts +1 -1
  81. package/server-managed/repository-browser.ts +123 -33
  82. package/server-managed/repository-code.ts +28 -11
  83. package/server-managed/repository-dependency-reviews.ts +41 -19
  84. package/server-managed/repository-history.ts +24 -8
  85. package/server-managed/repository-pulse.ts +9 -2
  86. package/server-managed/repository-risk-audit.ts +13 -0
  87. package/server-managed/repository-risk-read.ts +11 -6
  88. package/server-managed/repository-risk.ts +27 -20
  89. package/server-managed/session.ts +18 -4
  90. package/server-managed/solidity-entry-points.ts +5 -3
  91. package/server-managed/sql-postgres.ts +3 -0
  92. package/server-managed/sql.ts +6 -2
  93. package/server-managed/storage-db.ts +11 -3
  94. package/server-managed/storage-encryption.ts +7 -4
  95. package/server-managed/storage.ts +8 -4
  96. package/server-managed/team-feed.ts +3 -1
  97. package/server-managed/team-reports.ts +30 -13
  98. package/server-managed/uploads.ts +29 -9
  99. package/server-managed/upstream-cache.ts +12 -0
  100. package/server.ts +14 -5
  101. package/out/chunk-5DHL5NG3.js +0 -2
  102. package/out/chunk-6HNXNIQY.js +0 -52
  103. package/out/chunk-AFLZH7WG.js +0 -1
  104. package/out/chunk-NV7ES4EW.js +0 -1
  105. package/out/chunk-PTGDYLNS.js +0 -36
  106. package/out/chunk-Q6MVYGYV.js +0 -3
  107. package/out/chunk-QRD6GIG5.js +0 -229
  108. package/out/chunk-WI7Z5A2T.js +0 -6
  109. package/out/chunk-WQ74ZBZF.js +0 -7
  110. package/out/chunk-XVBEOGOS.js +0 -3
@@ -8,7 +8,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'
8
8
  import { WebSocket } from 'ws'
9
9
  import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
10
10
  import { revisionSchema } from './revisions.ts'
11
- import { MANAGED_SCHEMA, VIEW_SESSION_INDEX } from './db-schema.ts'
11
+ import { MANAGED_SCHEMA, REPO_UPLOAD_INDEXES, VIEW_SESSION_INDEX } from './db-schema.ts'
12
12
  import { STORAGE_SCHEMA } from './storage-db.ts'
13
13
  import { GITHUB_COMMIT_SCHEMA, GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
14
14
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
@@ -27,18 +27,26 @@ export interface PgConnection {
27
27
  rows: Record<string, unknown>[]; rowCount?: number | null
28
28
  fields?: { name: string; dataTypeID: number }[]
29
29
  }>
30
- release(): Promise<void>
30
+ // After a failure the connection's state is unknown: discard it, never reuse it.
31
+ release(discard?: boolean): Promise<void>
31
32
  }
32
33
  export type PgConnect = () => Promise<PgConnection>
33
34
  // Content/access mutations cooperate on this transaction-scoped lock. Reads,
34
35
  // presence updates, and maintenance leases do not take it. Also fences migrations.
35
36
  const LOCK = 'SELECT pg_advisory_xact_lock(1937006964, 1835101793)'
36
37
 
38
+ // Whether every version up to the latest is recorded, in one round trip at
39
+ // a cold start: a database with no versions table yet (undefined_table) has
40
+ // none. Outside a transaction, so the failed read aborts nothing.
41
+ const SCHEMA_VERSIONS = 31
37
42
  async function currentSchema(db: PgConnection): Promise<boolean> {
38
- const exists = (await db.query("SELECT to_regclass('managed_schema_version') AS name")).rows[0]?.['name']
39
- if (!exists) return false
40
- const versions = new Set((await db.query('SELECT version FROM managed_schema_version')).rows.map(row => Number(row['version'])))
41
- return Array.from({ length: 30 }, (_, i) => i + 1).every(version => versions.has(version))
43
+ try {
44
+ const { rows } = await db.query(`SELECT count(*) AS n FROM managed_schema_version WHERE version BETWEEN 1 AND ${SCHEMA_VERSIONS}`)
45
+ return Number(rows[0]?.['n']) === SCHEMA_VERSIONS
46
+ } catch (error) {
47
+ if ((error as { code?: unknown })?.code === '42P01') return false
48
+ throw error
49
+ }
42
50
  }
43
51
 
44
52
  async function migrateRepositoryDefaultCache(db: PgConnection): Promise<void> {
@@ -169,6 +177,12 @@ async function migrateRepositoryDependencyReviewLockfiles(db: PgConnection): Pro
169
177
  await db.query('INSERT INTO managed_schema_version VALUES (30)')
170
178
  }
171
179
 
180
+ async function migrateRepoUploadIndexes(db: PgConnection): Promise<void> {
181
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 31')).rows.length > 0) return
182
+ await db.query(REPO_UPLOAD_INDEXES)
183
+ await db.query('INSERT INTO managed_schema_version VALUES (31)')
184
+ }
185
+
172
186
  async function initialize(db: PgConnection): Promise<void> {
173
187
  if (await currentSchema(db)) return
174
188
  await db.query('BEGIN')
@@ -243,7 +257,7 @@ async function initialize(db: PgConnection): Promise<void> {
243
257
  await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (11)')
244
258
  }
245
259
  for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions, migrateSessionUploadKeys, migrateViewSessions, migrateGithubCommits, migrateTeamNpmScopes, migrateDependencyReviews, migrateGithubLinks, migrateRepositoryDependencyReviews,
246
- migrateRepositoryDependencyReviewLockfiles]) await migrate(db)
260
+ migrateRepositoryDependencyReviewLockfiles, migrateRepoUploadIndexes]) await migrate(db)
247
261
  await db.query('COMMIT')
248
262
  } catch (err) {
249
263
  await db.query('ROLLBACK')
@@ -276,7 +290,7 @@ function requestConnections(connect: PgConnect) {
276
290
  const connection = await connect()
277
291
  let result: T
278
292
  try { result = await work(connection) }
279
- catch (error) { await connection.release().catch(() => {}); throw error }
293
+ catch (error) { await connection.release(true).catch(() => {}); throw error }
280
294
  await connection.release()
281
295
  return result
282
296
  }
@@ -287,7 +301,7 @@ function requestConnections(connect: PgConnect) {
287
301
  try { return await work(connection) }
288
302
  catch (error) {
289
303
  delete request.connection
290
- await connection.release().catch(() => {})
304
+ await connection.release(true).catch(() => {})
291
305
  throw error
292
306
  }
293
307
  })
@@ -303,7 +317,7 @@ function requestConnections(connect: PgConnect) {
303
317
  finally {
304
318
  request.ended = true
305
319
  await request.queue
306
- if (failed) await request.connection?.release().catch(() => {})
320
+ if (failed) await request.connection?.release(true).catch(() => {})
307
321
  else await request.connection?.release()
308
322
  }
309
323
  }
@@ -313,7 +327,8 @@ function requestConnections(connect: PgConnect) {
313
327
  // Exposed for parity tests against real PostgreSQL semantics via PGlite.
314
328
  export async function openPostgresManagedDb(connect: PgConnect, options: ManagedDbOptions = {}, reuseConnections = false): Promise<ManagedDb> {
315
329
  const initial = await connect()
316
- try { await initialize(initial) } finally { await initial.release() }
330
+ try { await initialize(initial) } catch (err) { await initial.release(true).catch(() => {}); throw err }
331
+ await initial.release()
317
332
  const { withConnection, withRequest } = requestConnections(connect)
318
333
  const context = new AsyncLocalStorage<PgConnection>()
319
334
  let closed = false
@@ -376,20 +391,52 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
376
391
  return methods
377
392
  }
378
393
 
379
- export async function openNeonManagedDb(url: string, options: ManagedDbOptions = {}): Promise<ManagedDb> {
380
- // Reuse the e2e optional-driver boundary. Requests lease one connection and
381
- // close it before returning; no socket must survive a frozen invocation.
382
- const { Client, neonConfig } = await import('../server-e2e/neon-driver.ts') as unknown as {
383
- Client: new (url: string) => { connect(): Promise<void>; end(): Promise<void>; query: PgConnection['query']; on?(event: 'error', listener: (error: Error) => void): void }
394
+ // Idle connections stay open this long between leases, so a warm instance's
395
+ // requests and feed polls (three seconds apart) skip the TLS, WebSocket and
396
+ // Postgres handshake a new connection repeats.
397
+ const NEON_POOL_IDLE_MS = 5_000
398
+
399
+ type NeonPoolClient = {
400
+ query: PgConnection['query']
401
+ release(discard?: boolean): void
402
+ on(event: string, listener: (...args: unknown[]) => void): void
403
+ }
404
+ export type NeonPool = {
405
+ connect(): Promise<NeonPoolClient>
406
+ end(): Promise<void>
407
+ on(event: string, listener: (...args: unknown[]) => void): void
408
+ }
409
+
410
+ // `attachPool` is Vercel's attachDatabasePool on serverless instances: each
411
+ // release keeps the invocation alive until idle connections have closed, so
412
+ // no socket survives a frozen instance.
413
+ export async function openNeonManagedDb(url: string, options: ManagedDbOptions = {}, attachPool?: (pool: NeonPool) => void): Promise<ManagedDb> {
414
+ // Reuse the e2e optional-driver boundary.
415
+ const { Pool, neonConfig } = await import('../server-e2e/neon-driver.ts') as unknown as {
416
+ Pool: new (config: { connectionString: string; max: number; idleTimeoutMillis: number }) => NeonPool
384
417
  neonConfig: { webSocketConstructor: typeof WebSocket }
385
418
  }
386
419
  neonConfig.webSocketConstructor = WebSocket
387
- return openPostgresManagedDb(async () => {
388
- const client = new Client(url)
389
- // A remote disconnect between operations must reject the next query, not
390
- // become an unhandled EventEmitter error while the request is reading Blob.
391
- client.on?.('error', () => {})
392
- try { await client.connect() } catch (err) { await client.end().catch(() => {}); throw err }
393
- return { query: (sql, params) => client.query(sql, params), release: () => client.end() }
394
- }, options, true)
420
+ // Leases stay unbounded, one per request or operation as before: a cap would
421
+ // stall a request that holds one while it awaits work needing another.
422
+ const pool = new Pool({ connectionString: url, max: Infinity, idleTimeoutMillis: NEON_POOL_IDLE_MS })
423
+ // The pool evicts an idle connection the server closed. Neither that nor a
424
+ // remote disconnect while leased may become an unhandled EventEmitter error;
425
+ // a leased one must instead reject its next query, and is then discarded.
426
+ pool.on('error', () => {})
427
+ attachPool?.(pool)
428
+ const failed = new WeakSet<NeonPoolClient>(), watched = new WeakSet<NeonPoolClient>()
429
+ let ending: Promise<void> | undefined
430
+ const end = () => ending ??= pool.end()
431
+ let db: ManagedDb
432
+ try {
433
+ db = await openPostgresManagedDb(async () => {
434
+ const client = await pool.connect()
435
+ if (!watched.has(client)) { watched.add(client); client.on('error', () => { failed.add(client) }) }
436
+ return { query: (sql, params) => client.query(sql, params),
437
+ release: discard => Promise.try(() => client.release(discard === true || failed.has(client))) }
438
+ }, options, true)
439
+ } catch (err) { await end().catch(() => {}); throw err }
440
+ const close = db.close
441
+ return { ...db, async close() { try { await close() } finally { await end() } } }
395
442
  }
@@ -7,6 +7,10 @@ import { GITHUB_LINK_SCHEMA } from './github-links.ts'
7
7
 
8
8
  // Created after the column migration, which predates it on older databases.
9
9
  export const VIEW_SESSION_INDEX = 'CREATE INDEX IF NOT EXISTS managed_session_viewer_idx ON managed_session(viewer_session);'
10
+ // What a team's scopes join reports and bundles on: their repository, and the
11
+ // directory in it. Created after the column migrations, as above.
12
+ export const REPO_UPLOAD_INDEXES = `CREATE INDEX IF NOT EXISTS managed_report_repo_idx ON managed_report(repo_id, repo_directory);
13
+ CREATE INDEX IF NOT EXISTS managed_bundle_repo_idx ON managed_bundle(repo_id, repo_directory);`
10
14
 
11
15
  export const MANAGED_SCHEMA = `
12
16
  CREATE TABLE IF NOT EXISTS managed_user (
@@ -7,7 +7,7 @@ import { migrateReportLocations } from './report-migration.ts'
7
7
  import { initCommentMethods } from './comments.ts'
8
8
  import { initActivityMethods } from './activity.ts'
9
9
  import { revisionSchema } from './revisions.ts'
10
- import { MANAGED_SCHEMA, VIEW_SESSION_INDEX } from './db-schema.ts'
10
+ import { MANAGED_SCHEMA, REPO_UPLOAD_INDEXES, VIEW_SESSION_INDEX } from './db-schema.ts'
11
11
  import { GITHUB_LINK_USER_COLUMNS } from './github-links.ts'
12
12
  import { REPOSITORY_DEPENDENCY_REVIEW_COLUMNS } from './repository-dependency-reviews.ts'
13
13
  import { STORAGE_SCHEMA } from './storage-db.ts'
@@ -113,6 +113,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
113
113
  if (addedVisible) db.prepare('UPDATE managed_report SET visible = 1 WHERE visible = 0').run()
114
114
  ensureColumn(db, 'managed_bundle', 'uploaded_by_login', 'TEXT')
115
115
  ensureColumn(db, 'managed_bundle', 'repo_directory', "TEXT NOT NULL DEFAULT ''")
116
+ db.exec(REPO_UPLOAD_INDEXES)
116
117
  ensureColumn(db, 'managed_bundle', 'visible', 'INTEGER NOT NULL DEFAULT 1')
117
118
  // Existing rows stay NULL (unknown): some may have been built on the server.
118
119
  ensureColumn(db, 'managed_bundle', 'provenance', 'TEXT')
@@ -134,13 +134,23 @@ async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Pro
134
134
  JOIN managed_dependency_review r ON r.id = rt.review_id JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = ?
135
135
  WHERE rt.review_id IN (${placeholders}) AND (r.user_id = u.id OR (${reviewsIn(false)}))
136
136
  ORDER BY t.name, t.id`).all(user, ...ids) as (ReviewTeam & { reviewId: string })[]
137
+ // Each review's own, in their order, by its id.
138
+ const byReview = <T extends { reviewId: string }>(list: T[]) => {
139
+ const of = new Map<string, Omit<T, 'reviewId'>[]>()
140
+ for (const { reviewId, ...row } of list) {
141
+ const own = of.get(reviewId)
142
+ if (own) own.push(row)
143
+ else of.set(reviewId, [row])
144
+ }
145
+ return of
146
+ }
147
+ const [entriesOf, teamsOf] = [byReview(entries), byReview(linked)]
137
148
  return reviews.map(review => {
138
149
  const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
139
150
  const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
140
151
  const nonTextFiles = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
141
- const ownEntries = fileEntries(entries.filter(entry => entry.reviewId === review.id).map(({ reviewId: _reviewId, ...entry }) => entry))
142
- return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles),
143
- teams: linked.filter(team => team.reviewId === review.id).map(({ reviewId: _reviewId, ...team }) => team) }
152
+ const ownEntries = fileEntries((entriesOf.get(review.id) ?? []) as ReviewEntry[])
153
+ return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles), teams: (teamsOf.get(review.id) ?? []) as ReviewTeam[] }
144
154
  })
145
155
  }
146
156
 
@@ -7,8 +7,10 @@ import { createHash, createSign } from 'node:crypto'
7
7
  import { createClient } from '@preventive/upstream/github.js'
8
8
  import type { ManagedConfig } from './config.ts'
9
9
  import { GithubApiError, githubUnreachable, throwGithubResponseError, upstreamGithub } from './github-errors.ts'
10
+ import { onTokenRejected, shareGithubRead, tokenRejected } from './github-requests.ts'
10
11
 
11
12
  export { GithubApiError } from './github-errors.ts'
13
+ export { onTokenRejected } from './github-requests.ts'
12
14
 
13
15
  const GITHUB_API = 'https://api.github.com'
14
16
  const API_VERSION = '2022-11-28'
@@ -22,12 +24,12 @@ interface InstallationTokenEntry { token: string | null; expiresAt: number; pend
22
24
  // cached here. Bound memory per transport and separate App/key/install scopes.
23
25
  const installationTokens = new WeakMap<typeof fetch, Map<string, InstallationTokenEntry>>()
24
26
 
25
- function discardInstallationToken(token: string | null, fetchImpl: typeof fetch): void {
26
- if (!token) return
27
+ // A token GitHub rejected (401): an installation's no longer reused.
28
+ onTokenRejected((token, fetchImpl) => {
27
29
  const cache = installationTokens.get(fetchImpl)
28
30
  if (!cache) return
29
31
  for (const [key, entry] of cache) if (entry.token === token) cache.delete(key)
30
- }
32
+ })
31
33
 
32
34
  // One listed repository. Carries the context to read its contents later:
33
35
  // `installationId` mints an App installation token (Contents: Read) for a repo
@@ -69,8 +71,12 @@ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
69
71
  // One GitHub API call returning parsed JSON, optionally Bearer-authed by a
70
72
  // user token, App JWT, or installation token, `body` sent as JSON (a GraphQL
71
73
  // query's, given longer to answer). Network / non-2xx / malformed fold into a
72
- // GithubApiError (401 passes through for the caller to handle).
73
- export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown): Promise<unknown> {
74
+ // GithubApiError (401 passes through for the caller to handle). Identical GETs
75
+ // share an outstanding read through its body. POSTs only opt in for read-only
76
+ // GraphQL queries (repository-history.ts), never for mutations or token mints.
77
+ export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown,
78
+ readOnly = method === 'GET'): Promise<unknown> {
79
+ if (readOnly) return shareGithubRead(fetchImpl, JSON.stringify([url, token, method, body]), () => githubJson(url, token, fetchImpl, method, body, false))
74
80
  let res: Response
75
81
  const started = Date.now()
76
82
  try {
@@ -85,7 +91,7 @@ export async function githubJson(url: string, token: string | null, fetchImpl: t
85
91
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
86
92
  })
87
93
  } catch (error) { throw githubUnreachable(error, started) }
88
- if (res.status === 401) discardInstallationToken(token, fetchImpl)
94
+ if (res.status === 401 && token) tokenRejected(token, fetchImpl)
89
95
  if (!res.ok) await throwGithubResponseError(res)
90
96
  // The answer's body is read within the same timeout: one not read whole is
91
97
  // GitHub not reached, as one not answered is; only one that isn't JSON is
@@ -52,6 +52,15 @@ function readPage(job: Job, item: Issue | Pull, metadata: Map<string, Metadata>)
52
52
 
53
53
  function issueUpdates(jobs: Job[], issues: ManagedIssue[], allowed: Map<string, Repository>, checkedAt: number, metadata: Map<string, Metadata>) {
54
54
  const updates: IssueFixUpdate[] = []
55
+ // The issues each job's issue is, by repository and number, read once.
56
+ const byIssue = new Map<string, ManagedIssue[]>()
57
+ for (const issue of issues) {
58
+ const ref = parseGithubFixUrl(issue.issueUrl)
59
+ if (ref?.kind !== 'issue') continue
60
+ const key = `${issue.repoId}:${ref.number}`, same = byIssue.get(key)
61
+ if (same) same.push(issue)
62
+ else byIssue.set(key, [issue])
63
+ }
55
64
  for (const job of jobs) {
56
65
  if (!job.linked || !job.complete) continue
57
66
  // "Last" means the newest PR by creation time, never its latest comment.
@@ -62,11 +71,8 @@ function issueUpdates(jobs: Job[], issues: ManagedIssue[], allowed: Map<string,
62
71
  if (latest && (!repo || repo.repoId !== latest.repository.databaseId)) continue
63
72
  const next = latest ? `https://github.com/${repo!.github}/pull/${latest.number}` : null
64
73
  if (latest && next) metadata.set(next, { url: next, ...itemMetadata(latest, 'pull')! })
65
- for (const issue of issues) {
66
- const ref = parseGithubFixUrl(issue.issueUrl)
67
- if (ref?.kind === 'issue' && ref.number === job.number && issue.repoId === job.repo.repoId) {
68
- updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
69
- }
74
+ for (const issue of byIssue.get(`${job.repo.repoId}:${job.number}`) ?? []) {
75
+ updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
70
76
  }
71
77
  }
72
78
  return updates
@@ -222,6 +222,6 @@ export async function lookupFixes(config: ManagedConfig, db: ManagedDb, snapshot
222
222
  })
223
223
  }
224
224
 
225
- export function storedFixUrls(entries: { fix: string | null }[]): string[] {
226
- return [...new Set(entries.flatMap(entry => entry.fix && parseGithubFixUrl(entry.fix) ? [entry.fix] : []))].toSorted()
225
+ export function storedFixUrls(fixes: string[]): string[] {
226
+ return [...new Set(fixes.filter(fix => parseGithubFixUrl(fix)))].toSorted()
227
227
  }
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ // Only outstanding reads: a completed answer (or failure) is never reused.
4
+ // Keep transports and credentials separate, including anonymous reads. When
5
+ // full, new reads proceed normally rather than waiting for an unrelated one.
6
+ const MAX_PENDING_READS = 1000
7
+ type PendingRead = { promise: Promise<unknown>; shared: boolean }
8
+ const pendingReads = new WeakMap<typeof fetch, Map<string, PendingRead>>()
9
+
10
+ // Invalidate installation credentials and access verdicts when GitHub rejects
11
+ // a token, including when several callers share the rejected request.
12
+ const rejectedTokenListeners = new Set<(token: string, fetchImpl: typeof fetch) => void>()
13
+ export function onTokenRejected(listener: (token: string, fetchImpl: typeof fetch) => void): void {
14
+ rejectedTokenListeners.add(listener)
15
+ }
16
+ export function tokenRejected(token: string, fetchImpl: typeof fetch): void {
17
+ for (const listener of rejectedTokenListeners) listener(token, fetchImpl)
18
+ }
19
+
20
+ export async function shareGithubRead<T>(fetchImpl: typeof fetch, request: string, read: () => Promise<T>): Promise<T> {
21
+ let pending = pendingReads.get(fetchImpl)
22
+ if (!pending) { pending = new Map(); pendingReads.set(fetchImpl, pending) }
23
+ // The request includes credentials and query variables. Retain only its hash.
24
+ const key = createHash('sha256').update(request).digest('hex')
25
+ const held = pending.get(key)
26
+ if (held) {
27
+ held.shared = true
28
+ return structuredClone(await held.promise) as T
29
+ }
30
+ if (pending.size >= MAX_PENDING_READS) return read()
31
+ const entry: PendingRead = { promise: read(), shared: false }
32
+ pending.set(key, entry)
33
+ try {
34
+ const value = await entry.promise
35
+ // Independent objects for coalesced callers, without copying large trees
36
+ // or histories when nobody joined the read.
37
+ return (entry.shared ? structuredClone(value) : value) as T
38
+ } finally { pending.delete(key) }
39
+ }
@@ -15,8 +15,16 @@ export function attachmentDisposition(filename: string): string {
15
15
  // already materialized by the authorization/filtering layer before we get here.
16
16
  export function writeResponse(res: ServerResponse, body: string | Buffer): void {
17
17
  const bytes = typeof body === 'string' ? Buffer.from(body) : body
18
- if (bytes.length <= UPLOAD_CHUNK_BYTES) { res.end(body); return }
19
- for (let offset = 0; offset < bytes.length; offset += 64 * 1024) res.write(bytes.subarray(offset, offset + 64 * 1024))
18
+ writeResponseParts(res, [bytes], bytes.length)
19
+ }
20
+
21
+ // A body of `length` bytes held in parts, written as writeResponse writes
22
+ // one without first copying them into one buffer.
23
+ export function writeResponseParts(res: ServerResponse, parts: Buffer[], length: number): void {
24
+ if (length <= UPLOAD_CHUNK_BYTES) { res.end(parts.length === 1 ? parts[0] : Buffer.concat(parts, length)); return }
25
+ for (const part of parts) {
26
+ for (let offset = 0; offset < part.length; offset += 64 * 1024) res.write(part.subarray(offset, offset + 64 * 1024))
27
+ }
20
28
  res.end()
21
29
  }
22
30