@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.
- package/client/linked-findings.js +3 -0
- package/common/bundle-metadata.d.ts +1 -0
- package/common/bundle-metadata.js +1 -1
- package/common/bundle-sources.js +5 -1
- package/common/line-diff.js +5 -4
- package/common/managed/agent-tasks.ts +5 -3
- package/common/managed/github-agents.ts +28 -10
- package/out/activity.js +4 -0
- package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
- package/out/chunk-5AHIQFFG.js +36 -0
- package/out/chunk-6ANSHRFQ.js +6 -0
- package/out/chunk-6WF3KNTL.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/{chunk-HMAJDVXN.js → chunk-FH24VHSJ.js} +1 -1
- package/out/chunk-NOZJULWS.js +52 -0
- package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
- package/out/chunk-S6U2J62U.js +232 -0
- package/out/chunk-SPEFPML6.js +2 -0
- package/out/chunk-UENXT2HK.js +3 -0
- package/out/chunk-VL43BTSO.js +1 -0
- package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
- package/out/chunk-ZCS4UYHI.js +1 -0
- package/out/chunk-ZGNMDGSB.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +15 -15
- package/out/client-sync.js +2 -2
- package/out/compare.js +12 -12
- package/out/dialogs.js +28 -28
- package/out/graph.js +11 -11
- package/out/index.html +11 -11
- package/out/scan.js +2 -2
- package/out/terminal.js +6 -6
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +4 -1
- package/risk/actions/findings.js +14 -9
- package/risk/actions/scripts/shell.js +5 -1
- package/risk/index.d.ts +9 -4
- package/risk/npm/assess.js +15 -4
- package/risk/npm/registry.js +6 -1
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/activity.ts +24 -11
- package/server-managed/app-agents.ts +10 -21
- package/server-managed/bundle-build-worker.js +7 -3
- package/server-managed/bundle-build.ts +5 -3
- package/server-managed/bundle-cache.ts +16 -4
- package/server-managed/bundle.ts +3 -4
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-coauthors.ts +5 -9
- package/server-managed/commit-lockfiles.ts +21 -11
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +321 -156
- package/server-managed/db-neon.ts +71 -24
- package/server-managed/db-schema.ts +4 -0
- package/server-managed/db.ts +2 -1
- package/server-managed/dependency-reviews.ts +13 -3
- package/server-managed/github-app.ts +12 -6
- package/server-managed/github-issue-links.ts +11 -5
- package/server-managed/github-pulls.ts +2 -2
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/http-response.ts +10 -2
- package/server-managed/http.ts +269 -143
- package/server-managed/index.ts +8 -7
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/link-reports.ts +5 -1
- package/server-managed/npm-delta.ts +11 -6
- package/server-managed/npm-kept.ts +51 -7
- package/server-managed/npm-loads.ts +117 -35
- package/server-managed/npm-packages.ts +107 -14
- package/server-managed/npm-risk.ts +68 -18
- package/server-managed/pretty-print.ts +112 -35
- package/server-managed/pull-dependencies.ts +29 -9
- package/server-managed/pull-dependency-reviews.ts +8 -8
- package/server-managed/queue.ts +16 -0
- package/server-managed/rename-diff.ts +22 -13
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/report-store.ts +50 -12
- package/server-managed/repository-actions.ts +15 -6
- package/server-managed/repository-agents.ts +1 -1
- package/server-managed/repository-browser.ts +123 -33
- package/server-managed/repository-code.ts +28 -11
- package/server-managed/repository-dependency-reviews.ts +41 -19
- package/server-managed/repository-history.ts +24 -8
- package/server-managed/repository-pulse.ts +9 -2
- package/server-managed/repository-risk-audit.ts +13 -0
- package/server-managed/repository-risk-read.ts +11 -6
- package/server-managed/repository-risk.ts +27 -20
- package/server-managed/session.ts +18 -4
- package/server-managed/solidity-entry-points.ts +5 -3
- package/server-managed/sql-postgres.ts +3 -0
- package/server-managed/sql.ts +6 -2
- package/server-managed/storage-db.ts +11 -3
- package/server-managed/storage-encryption.ts +7 -4
- package/server-managed/storage.ts +8 -4
- package/server-managed/team-feed.ts +3 -1
- package/server-managed/team-reports.ts +30 -13
- package/server-managed/uploads.ts +29 -9
- package/server-managed/upstream-cache.ts +12 -0
- package/server.ts +14 -5
- package/out/chunk-5DHL5NG3.js +0 -2
- package/out/chunk-6HNXNIQY.js +0 -52
- package/out/chunk-AFLZH7WG.js +0 -1
- package/out/chunk-NV7ES4EW.js +0 -1
- package/out/chunk-PTGDYLNS.js +0 -36
- package/out/chunk-Q6MVYGYV.js +0 -3
- package/out/chunk-QRD6GIG5.js +0 -229
- package/out/chunk-WI7Z5A2T.js +0 -6
- package/out/chunk-WQ74ZBZF.js +0 -7
- 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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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) }
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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 (
|
package/server-managed/db.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
66
|
-
|
|
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(
|
|
226
|
-
return [...new Set(
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|