@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +25 -2
- package/common/default-scan-models.ts +1 -1
- package/common/dependency-reviews.d.ts +0 -5
- package/common/dependency-reviews.js +0 -17
- package/common/line-diff.js +5 -4
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/github-agents.ts +92 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/roles.ts +7 -0
- package/common/managed/routes.js +88 -7
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +3 -0
- package/common/workspace-app.js +6 -3
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2XUEZHIP.js +18 -18
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-3PS4KWD2.js +10 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4UFAMJAX.js +1 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7XRR4OFU.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-BOIXMUU5.js +3 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-CC4OM6TZ.js +3 -0
- package/out/chunk-G24A42V6.js +2 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-I2MDGSQX.js +3 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -1
- package/out/chunk-JKGQ3O7O.js +6 -0
- package/out/chunk-LE5ZWLG4.js +235 -0
- package/out/chunk-MSION3G4.js +36 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-S6GIXWQW.js +52 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-V3KWNNMY.js +1 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/client-managed-import.js +3 -3
- package/out/client-managed.js +31 -35
- package/out/client-sync.js +11 -11
- package/out/compare.js +35 -0
- package/out/dialogs.js +51 -0
- package/out/github-issue-authorized.html +1 -1
- package/out/github-issue-authorized.js +1 -1
- package/out/graph.js +17 -17
- package/out/index.html +30 -34
- package/out/prism.js +14 -14
- package/out/scan.js +2 -0
- package/out/terminal.js +294 -293
- package/out/view.css +1 -1
- package/out/view.js +16 -107
- package/package.json +53 -6
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/hosts.js +6 -6
- package/risk/files/readability.js +23 -17
- package/risk/files/runs-code.js +18 -6
- package/risk/files/script.js +7 -3
- package/risk/files/stamps.js +8 -7
- package/risk/index.d.ts +71 -1
- package/risk/index.js +6 -2
- package/risk/npm/assess.js +1 -1
- package/risk/npm/compare.js +34 -10
- package/risk/npm/files.js +413 -2
- package/risk/npm/tarball.js +56 -73
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/agent-merges.ts +198 -0
- package/server-managed/app-agents.ts +30 -0
- package/server-managed/avatar-store.ts +1 -0
- package/server-managed/bundle-cache.ts +7 -1
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +72 -0
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +66 -19
- package/server-managed/db-neon.ts +89 -21
- package/server-managed/db-schema.ts +13 -2
- package/server-managed/db.ts +9 -1
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +97 -0
- package/server-managed/dependency-reviews.ts +78 -18
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +33 -13
- package/server-managed/github-errors.ts +2 -0
- package/server-managed/github-issue-links.ts +11 -5
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-oauth.ts +5 -2
- package/server-managed/github-pulls.ts +2 -2
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +10 -2
- package/server-managed/http.ts +1251 -97
- package/server-managed/index.ts +13 -8
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/locked-packages.ts +37 -16
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +178 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +83 -6
- package/server-managed/npm-loads.ts +114 -37
- package/server-managed/npm-packages.ts +110 -64
- package/server-managed/npm-risk.ts +4 -1
- package/server-managed/pretty-print.ts +67 -23
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +300 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +16 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff.ts +22 -13
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/report-store.ts +42 -9
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +166 -37
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +157 -0
- package/server-managed/repository-history.ts +461 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +439 -0
- package/server-managed/repository-risk-audit.ts +13 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +133 -118
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +27 -9
- package/server-managed/sql.ts +6 -3
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-app.ts +19 -3
- package/server-managed/team-reports.ts +24 -12
- package/server-managed/upstream-cache.ts +12 -0
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +25 -5
- package/out/chunk-2ABLKCOY.js +0 -46
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-5LSXOLUS.js +0 -1
- package/out/chunk-5RCM2J63.js +0 -1
- package/out/chunk-7B6HB4D7.js +0 -1
- package/out/chunk-AJ4GTATZ.js +0 -3
- package/out/chunk-CH4UN6WK.js +0 -225
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-EDKLBQQN.js +0 -17
- package/out/chunk-F47DXCEV.js +0 -33
- package/out/chunk-FK3DZEKL.js +0 -1
- package/out/chunk-G6AJWENO.js +0 -1
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- package/out/chunk-GZWM7UQM.js +0 -2
- package/out/chunk-IU3CCINR.js +0 -3
- package/out/chunk-K3X5EJBQ.js +0 -2
- package/out/chunk-KEX2ATEW.js +0 -2
- package/out/chunk-KYCNJVB7.js +0 -1
- package/out/chunk-LPCTJYNA.js +0 -7
- package/out/chunk-O4RJY5TP.js +0 -2
- package/out/chunk-OD4LPN3H.js +0 -1
- package/out/chunk-QWDWTBM7.js +0 -1
- package/out/chunk-QYWKC557.js +0 -1
- package/out/chunk-R75XOKUP.js +0 -1
- package/out/chunk-SWTLADW5.js +0 -1
- package/out/chunk-T7OKH4VW.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UTR2LVME.js +0 -2
- package/out/chunk-UYAXVRVL.js +0 -1
- package/out/chunk-V3ME6FI5.js +0 -2
- package/out/chunk-V5O7M2YT.js +0 -1
- package/out/chunk-VIHGO77Y.js +0 -1
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-ZESLQCTF.js +0 -6
|
@@ -12,16 +12,17 @@ import { promisify } from 'node:util'
|
|
|
12
12
|
import { brotliDecompress } from 'node:zlib'
|
|
13
13
|
import { format } from '@preventive/compare/pretty.js'
|
|
14
14
|
import { computeFileHash } from '@preventive/report'
|
|
15
|
-
import { FILE_HASH } from '../common/bundle-metadata.js'
|
|
15
|
+
import { FILE_HASH, computeBundleFileHashes } from '../common/bundle-metadata.js'
|
|
16
16
|
import { bundleCompareFiles } from '../common/bundle-compare-inputs.js'
|
|
17
17
|
import { bundlePackageDirs, bundleSourcesAsMap } from '../common/bundle-sources.js'
|
|
18
18
|
import { MAX_PRETTY_BYTES, needsPrettyPrint, prettyExtension } from '../common/pretty-print.js'
|
|
19
19
|
import type { OpenedBlob } from './blob-store.ts'
|
|
20
|
-
import { type BundleCacheStorage,
|
|
20
|
+
import { type BundleCacheStorage, readSizedBundleDetails } from './bundle-cache.ts'
|
|
21
21
|
import type { BundleStore } from './bundle-store.ts'
|
|
22
22
|
import { encodeBrotli } from './brotli.ts'
|
|
23
23
|
import { CacheMissError, type CacheStorage } from './cache-storage.ts'
|
|
24
24
|
import type { ManagedBundle, ManagedDb } from './db.ts'
|
|
25
|
+
import { keptBriefly } from './kept-briefly.ts'
|
|
25
26
|
import { loadNpmFiles } from './npm-loads.ts'
|
|
26
27
|
import { type NpmVersionDocument, npmFileText } from './npm-packages.ts'
|
|
27
28
|
|
|
@@ -34,6 +35,16 @@ const PRETTY_VERSION = 4
|
|
|
34
35
|
// after another, beside other readers'), any more refused.
|
|
35
36
|
const MAX_ACTIVE_FORMATS = 2
|
|
36
37
|
const MAX_WAITING_FORMATS = 16
|
|
38
|
+
// How long a bundle's details are kept once read, and what the two last read
|
|
39
|
+
// may have decoded from between them, the least lately read going first past
|
|
40
|
+
// it: Compare's requests for a pair of bundles come one after another, each
|
|
41
|
+
// reading both, and the Code tab's for one, each reading it.
|
|
42
|
+
const DETAILS_KEPT_MS = 60_000
|
|
43
|
+
const MAX_KEPT_DETAILS = 2
|
|
44
|
+
const MAX_KEPT_DETAILS_BYTES = 256 * 1024 * 1024
|
|
45
|
+
// Files hashed at once: one at a time, each waits its own round trip to the
|
|
46
|
+
// thread pool; all at once, every one's job is held together.
|
|
47
|
+
const HASHES_AT_ONCE = 32
|
|
37
48
|
|
|
38
49
|
export class PrettyError extends Error {
|
|
39
50
|
status: number
|
|
@@ -108,6 +119,18 @@ export async function prettyBody(text: string, extension: string): Promise<Buffe
|
|
|
108
119
|
|
|
109
120
|
const opened = (body: Buffer): OpenedBlob => ({ size: body.byteLength, stream: Readable.from([body]) })
|
|
110
121
|
|
|
122
|
+
// A version's file's hash, by its bytes: those of a version's files loaded
|
|
123
|
+
// a little while since are the same bytes (npm-loads.ts), hashed once.
|
|
124
|
+
const fileHashes = new WeakMap<Uint8Array, Promise<string>>()
|
|
125
|
+
function npmFileHash(bytes: Uint8Array): Promise<string> {
|
|
126
|
+
let hash = fileHashes.get(bytes)
|
|
127
|
+
if (!hash) {
|
|
128
|
+
fileHashes.set(bytes, hash = computeFileHash(bytes))
|
|
129
|
+
hash.catch(() => { fileHashes.delete(bytes) })
|
|
130
|
+
}
|
|
131
|
+
return hash
|
|
132
|
+
}
|
|
133
|
+
|
|
111
134
|
const decompress = promisify(brotliDecompress)
|
|
112
135
|
|
|
113
136
|
// A pretty-printed copy's text, from its Brotli-encoded bytes.
|
|
@@ -127,7 +150,7 @@ async function npmPrintable(doc: NpmVersionDocument): Promise<Array<PrettyReques
|
|
|
127
150
|
const extension = prettyExtension(path)
|
|
128
151
|
if (extension === null || bytes.byteLength > MAX_PRETTY_BYTES) continue
|
|
129
152
|
const text = npmFileText(bytes)
|
|
130
|
-
if (text !== null && needsPrettyPrint(path, text)) files.push({ path, hash: await
|
|
153
|
+
if (text !== null && needsPrettyPrint(path, text)) files.push({ path, hash: await npmFileHash(bytes), extension, text })
|
|
131
154
|
}
|
|
132
155
|
} finally { load.release() }
|
|
133
156
|
return files.toSorted((a, b) => (a.path < b.path ? -1 : 1))
|
|
@@ -206,26 +229,35 @@ async function kept(open: () => Promise<OpenedBlob>): Promise<OpenedBlob | null>
|
|
|
206
229
|
|
|
207
230
|
// A side's files that can be formatted, with their texts, and every one of
|
|
208
231
|
// its files' hashes by path, from their paths and contents: texts, or bytes,
|
|
209
|
-
// read as text where they are.
|
|
210
|
-
async function sideFiles(entries:
|
|
232
|
+
// read as text where they are; hashed by `hashOf`, a few at once.
|
|
233
|
+
async function sideFiles<T extends string | Uint8Array>(entries: Array<[string, T]>, hashOf: (file: string, content: T) => Promise<string>): Promise<Pick<ComparedSide, 'files' | 'hashes'>> {
|
|
211
234
|
const files: ComparedSide['files'] = [], hashes = new Map<string, string>()
|
|
235
|
+
for (let i = 0; i < entries.length; i += HASHES_AT_ONCE) {
|
|
236
|
+
const chunk = entries.slice(i, i + HASHES_AT_ONCE)
|
|
237
|
+
const hashed = await Promise.all(chunk.map(async ([path, content]) => [path, await hashOf(path, content)] as const))
|
|
238
|
+
for (const [path, hash] of hashed) hashes.set(path, hash)
|
|
239
|
+
}
|
|
212
240
|
for (const [path, content] of entries) {
|
|
213
|
-
const hash = await computeFileHash(content)
|
|
214
|
-
hashes.set(path, hash)
|
|
215
241
|
const extension = prettyExtension(path)
|
|
216
242
|
if (extension === null || (typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength) > MAX_PRETTY_BYTES) continue
|
|
217
243
|
const text = typeof content === 'string' ? content : npmFileText(content)
|
|
218
|
-
if (text !== null) files.push({ path, hash
|
|
244
|
+
if (text !== null) files.push({ path, hash: hashes.get(path)!, extension, text })
|
|
219
245
|
}
|
|
220
246
|
return { files, hashes }
|
|
221
247
|
}
|
|
222
248
|
|
|
223
249
|
// A bundle's side, from its details: its files, as sideFiles reads them,
|
|
224
250
|
// within a reason `scope` as Compare reads them there (bundleCompareFiles),
|
|
225
|
-
// and its packages' directories
|
|
251
|
+
// and its packages' directories; hashed once for its details, as long as
|
|
252
|
+
// they are kept (computeBundleFileHashes).
|
|
226
253
|
async function bundleSide(details: Parameters<typeof bundlePackageDirs>[0], scope: string): Promise<Pick<ComparedSide, 'files' | 'hashes' | 'packageDirs'>> {
|
|
227
254
|
const scoped = scope ? new Set(bundleCompareFiles(details, scope).keys()) : null, sources = bundleSourcesAsMap(details)
|
|
228
|
-
|
|
255
|
+
const hashed: Map<string, string> = await computeBundleFileHashes(details)
|
|
256
|
+
return { ...await sideFiles(scoped ? [...sources].filter(([path]) => scoped.has(path)) : [...sources], (path, content) => {
|
|
257
|
+
const hash = hashed.get(path)
|
|
258
|
+
return hash === undefined ? computeFileHash(content) : Promise.resolve(hash)
|
|
259
|
+
}),
|
|
260
|
+
packageDirs: bundlePackageDirs(details) }
|
|
229
261
|
}
|
|
230
262
|
|
|
231
263
|
// A version's files by path, from one load of them.
|
|
@@ -251,14 +283,25 @@ export function stalePrettyRemover(npm: CacheStorage): (signal?: AbortSignal) =>
|
|
|
251
283
|
}
|
|
252
284
|
|
|
253
285
|
// A bundle's details, from its bytes in `store`: bundles are read one at a
|
|
254
|
-
// time, as reading one decodes all of it
|
|
286
|
+
// time, as reading one decodes all of it, readers asking for one waiting its
|
|
287
|
+
// turn or being read sharing it; and kept a little while once read
|
|
288
|
+
// (DETAILS_KEPT_MS), each reader's access to the bundle the caller's to check
|
|
289
|
+
// as ever.
|
|
255
290
|
function detailsReader(store: BundleStore) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
291
|
+
type Details = NonNullable<Awaited<ReturnType<typeof readSizedBundleDetails>>>['details']
|
|
292
|
+
const enqueue = serial(), share = sharing<Details>()
|
|
293
|
+
const held = keptBriefly<Details>({ keptMs: DETAILS_KEPT_MS, maxBytes: MAX_KEPT_DETAILS_BYTES, maxEntries: MAX_KEPT_DETAILS })
|
|
294
|
+
return (record: ManagedBundle): Promise<Details> => {
|
|
295
|
+
const key = `${record.id} ${record.integrity}`
|
|
296
|
+
const hit = held.get(key)
|
|
297
|
+
if (hit) return Promise.resolve(hit)
|
|
298
|
+
return share(key, () => enqueue(async () => {
|
|
299
|
+
const read = await readSizedBundleDetails(record, store)
|
|
300
|
+
if (!read) throw new PrettyError(422, 'bundle-unavailable')
|
|
301
|
+
held.keep(key, read.details, read.decodedBytes)
|
|
302
|
+
return read.details
|
|
303
|
+
}))
|
|
304
|
+
}
|
|
262
305
|
}
|
|
263
306
|
|
|
264
307
|
export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage, db: Pick<ManagedDb, 'getBundle'>, store: BundleStore) {
|
|
@@ -317,7 +360,7 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
|
|
|
317
360
|
try {
|
|
318
361
|
const bytes = (await load.result).find(entry => entry.path === request.path)?.bytes
|
|
319
362
|
if (!bytes) throw new PrettyError(404, 'no-file')
|
|
320
|
-
if (await
|
|
363
|
+
if (await npmFileHash(bytes) !== request.hash) throw new PrettyError(409, 'hash-mismatch')
|
|
321
364
|
text = npmFileText(bytes)
|
|
322
365
|
} finally { load.release() }
|
|
323
366
|
if (text === null) throw new PrettyError(422, 'unformattable')
|
|
@@ -338,19 +381,20 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
|
|
|
338
381
|
// pretty-printed (compared), and their diffs with renamed names hidden
|
|
339
382
|
// (rename-diff.ts renamedPairs); a bundle's within a reason `scope`
|
|
340
383
|
// where one is asked for, as Compare reads it then.
|
|
341
|
-
|
|
384
|
+
bundleSides(base: ManagedBundle, other: ManagedBundle, scope = ''): Promise<[ComparedSide, ComparedSide]> {
|
|
342
385
|
const sideOf = async (record: ManagedBundle, side: Side): Promise<ComparedSide> => ({ side, ...await bundleSide(await bundleDetails(record), scope),
|
|
343
386
|
kept: request => kept(() => bundles.open(record.id, prettyFile(request))),
|
|
344
387
|
made: async (request, text) => opened(await bundleCopy(record, request, () => Promise.resolve(text))) })
|
|
345
|
-
return [
|
|
388
|
+
return Promise.all([sideOf(base, 'base'), sideOf(other, 'other')])
|
|
346
389
|
},
|
|
347
390
|
// A bundle's details, read in its turn, as Compare pairs them (comparison.ts).
|
|
348
391
|
bundleDetails,
|
|
349
|
-
|
|
350
|
-
const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc)
|
|
392
|
+
npmSides(base: NpmVersionDocument, other: NpmVersionDocument): Promise<[ComparedSide, ComparedSide]> {
|
|
393
|
+
const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc), (_, bytes) => npmFileHash(bytes)),
|
|
394
|
+
packageDirs: null,
|
|
351
395
|
kept: request => (doc.private ? Promise.resolve(null) : kept(() => npm.open(prettyFile(request)))),
|
|
352
396
|
made: (request, text) => npmCopy(doc, request, () => Promise.resolve(text)) })
|
|
353
|
-
return [
|
|
397
|
+
return Promise.all([sideOf(base, 'base'), sideOf(other, 'other')])
|
|
354
398
|
},
|
|
355
399
|
}
|
|
356
400
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The reviews of dependencies an open pull request's comments make, as the
|
|
2
|
+
// host's parser reads them (reviewed-dependencies.ts `parseComments`): its
|
|
3
|
+
// comments, its reviews' own, whatever their state, and those on lines of
|
|
4
|
+
// its diff, read of GitHub's GraphQL API, as many as MAX_COMMENTS, with
|
|
5
|
+
// whether it is open, kept in this instance KEPT_MS; none of one not open;
|
|
6
|
+
// the parser's answer to the same kept PARSED_KEPT_MS.
|
|
7
|
+
// A review counts only where its user is one of their authors and the app's
|
|
8
|
+
// user that account is (by its numeric id, else its login, its linked
|
|
9
|
+
// accounts' too) publishes reviews to a team counting for the repository
|
|
10
|
+
// (`reviewers`): by that user, as their reviews here are, each once among
|
|
11
|
+
// them (pull-dependency-reviews.ts), as of its time, else their latest
|
|
12
|
+
// comment's. They count for the pull request alone, never stored.
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { ACTOR, type GraphAccount, actorOf } from './github-accounts.ts'
|
|
15
|
+
import { type Lru, lru } from './lru.ts'
|
|
16
|
+
import type { CommentReviews } from './pull-dependency-reviews.ts'
|
|
17
|
+
import type { PublishedReview } from './repository-dependency-reviews.ts'
|
|
18
|
+
import { githubGraphql } from './repository-history.ts'
|
|
19
|
+
import type { CommentReview, ParseCommentsInput, ReviewComment, ReviewVerdict, ReviewedDependencies } from './reviewed-dependencies.ts'
|
|
20
|
+
|
|
21
|
+
const MAX_COMMENTS = 1000
|
|
22
|
+
const KEPT_MS = 60_000
|
|
23
|
+
const PARSED_KEPT_MS = 10 * 60_000
|
|
24
|
+
const VERDICTS = new Set<string>(['accept', 'accept-with-nits', 'request-changes', 'reject'])
|
|
25
|
+
|
|
26
|
+
// A comment as read: when it was written, and last edited, where it was.
|
|
27
|
+
const COMMENT = `id author { ${ACTOR} } body createdAt lastEditedAt url`
|
|
28
|
+
// Its comments and its reviews, each a page on from its cursor, each only
|
|
29
|
+
// while it has more (`readComments`, `readReviews`).
|
|
30
|
+
const QUERY = `query($owner: String!, $name: String!, $number: Int!, $comments: String, $reviews: String, $readComments: Boolean!, $readReviews: Boolean!) {
|
|
31
|
+
repository(owner: $owner, name: $name) { pullRequest(number: $number) { state
|
|
32
|
+
comments(first: 100, after: $comments) @include(if: $readComments) { pageInfo { hasNextPage endCursor } nodes { ${COMMENT} } }
|
|
33
|
+
reviews(first: 50, after: $reviews) @include(if: $readReviews) { pageInfo { hasNextPage endCursor }
|
|
34
|
+
nodes { ${COMMENT} state comments(first: 100) { pageInfo { hasNextPage endCursor } nodes { ${COMMENT} } } } }
|
|
35
|
+
} } }`
|
|
36
|
+
// A review's comments on lines of its diff, a page on from its cursor.
|
|
37
|
+
const REVIEW_COMMENTS = `query($id: ID!, $after: String) { node(id: $id) { ... on PullRequestReview {
|
|
38
|
+
comments(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { ${COMMENT} } } } } }`
|
|
39
|
+
|
|
40
|
+
// A comment as read, and its author's account.
|
|
41
|
+
interface Authored { comment: ReviewComment; account: { login: string | null; id: number | null } }
|
|
42
|
+
type Page = { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: unknown }
|
|
43
|
+
type Node = { id?: unknown; author?: GraphAccount; body?: unknown; state?: unknown; createdAt?: unknown; lastEditedAt?: unknown; url?: unknown; comments?: Page }
|
|
44
|
+
|
|
45
|
+
// A pull request's comments as read, and whether it is open.
|
|
46
|
+
interface PullComments { open: boolean; read: Authored[] }
|
|
47
|
+
const comments: Lru<{ at: number } & PullComments> = lru(200)
|
|
48
|
+
const answers: Lru<{ at: number; reviews: CommentReview[] }> = lru(2000)
|
|
49
|
+
|
|
50
|
+
function authored(node: Node, kind: ReviewComment['kind']): Authored[] {
|
|
51
|
+
if (typeof node?.id !== 'string' || typeof node.body !== 'string' || typeof node.createdAt !== 'string') return []
|
|
52
|
+
const account = actorOf(node.author)
|
|
53
|
+
return [{ account, comment: { kind, id: node.id, author: account.login, body: node.body, ...typeof node.state === 'string' ? { state: node.state } : {},
|
|
54
|
+
createdAt: node.createdAt, ...typeof node.lastEditedAt === 'string' ? { editedAt: node.lastEditedAt } : {}, url: typeof node.url === 'string' ? node.url : '' } }]
|
|
55
|
+
}
|
|
56
|
+
const nodes = (list: unknown): Node[] => Array.isArray(list) ? list as Node[] : []
|
|
57
|
+
// The cursor a page ends at, where more follow it.
|
|
58
|
+
const next = (page: Page | undefined) => page?.pageInfo?.hasNextPage === true && typeof page.pageInfo.endCursor === 'string' ? page.pageInfo.endCursor : null
|
|
59
|
+
|
|
60
|
+
// Review `review`'s comments on lines of its diff: the page read with it,
|
|
61
|
+
// then those after it read of GitHub, till as many as `room` are.
|
|
62
|
+
async function inlineComments(review: Node, room: number, token: string, fetchImpl: typeof fetch): Promise<Authored[]> {
|
|
63
|
+
const read = nodes(review.comments?.nodes).flatMap(inline => authored(inline, 'review-comment'))
|
|
64
|
+
for (let after = typeof review.id === 'string' ? next(review.comments) : null; after !== null && read.length < room;) {
|
|
65
|
+
const data = await githubGraphql(REVIEW_COMMENTS, { id: review.id, after }, token, fetchImpl) as { node?: { comments?: Page } | null } | null
|
|
66
|
+
read.push(...nodes(data?.node?.comments?.nodes).flatMap(inline => authored(inline, 'review-comment')))
|
|
67
|
+
after = next(data?.node?.comments)
|
|
68
|
+
}
|
|
69
|
+
return read
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Pull request `number`'s comments, its reviews' and those on its diff, each
|
|
73
|
+
// review's all of them, and whether it is open, as kept, else read of GitHub
|
|
74
|
+
// a page at a time.
|
|
75
|
+
export async function pullComments(fullName: string, token: string, number: number, fetchImpl: typeof fetch = globalThis.fetch): Promise<PullComments> {
|
|
76
|
+
const key = `${fullName}\0${number}`
|
|
77
|
+
const known = comments.get(key)
|
|
78
|
+
if (known && Date.now() - known.at < KEPT_MS) return known
|
|
79
|
+
const [owner, name] = fullName.split('/') as [string, string]
|
|
80
|
+
const read: Authored[] = []
|
|
81
|
+
let open: boolean | null = null
|
|
82
|
+
// Each connection read on from its cursor till it has no more, the other
|
|
83
|
+
// read on without it, never again from its first page.
|
|
84
|
+
let cursors: { comments: string | null; reviews: string | null } = { comments: null, reviews: null }
|
|
85
|
+
let reading = { comments: true, reviews: true }
|
|
86
|
+
while ((reading.comments || reading.reviews) && read.length < MAX_COMMENTS) {
|
|
87
|
+
const data = await githubGraphql(QUERY, { owner, name, number, ...cursors, readComments: reading.comments, readReviews: reading.reviews }, token, fetchImpl) as
|
|
88
|
+
{ repository?: { pullRequest?: { state?: unknown; comments?: Page; reviews?: Page } | null } | null } | null
|
|
89
|
+
const pull = data?.repository?.pullRequest
|
|
90
|
+
if (!pull) break
|
|
91
|
+
open ??= pull.state === 'OPEN'
|
|
92
|
+
for (const node of nodes(pull.comments?.nodes)) read.push(...authored(node, 'comment'))
|
|
93
|
+
for (const node of nodes(pull.reviews?.nodes)) {
|
|
94
|
+
read.push(...authored(node, 'review'))
|
|
95
|
+
read.push(...await inlineComments(node, MAX_COMMENTS - read.length, token, fetchImpl))
|
|
96
|
+
}
|
|
97
|
+
cursors = { comments: reading.comments ? next(pull.comments) : null, reviews: reading.reviews ? next(pull.reviews) : null }
|
|
98
|
+
reading = { comments: cursors.comments !== null, reviews: cursors.reviews !== null }
|
|
99
|
+
}
|
|
100
|
+
const kept = { open: open === true, read: read.slice(0, MAX_COMMENTS) }
|
|
101
|
+
comments.set(key, { at: Date.now(), ...kept })
|
|
102
|
+
return kept
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The parser's reviews of `input`, as kept for the same, else asked.
|
|
106
|
+
async function parsed(parse: NonNullable<ReviewedDependencies['parseComments']>, input: ParseCommentsInput): Promise<CommentReview[]> {
|
|
107
|
+
const key = createHash('sha256').update(JSON.stringify(input)).digest('base64url')
|
|
108
|
+
const known = answers.get(key)
|
|
109
|
+
if (known && Date.now() - known.at < PARSED_KEPT_MS) return known.reviews
|
|
110
|
+
const reviews = await parse(structuredClone(input))
|
|
111
|
+
if (!Array.isArray(reviews)) throw new TypeError('parseComments answered with no list')
|
|
112
|
+
answers.set(key, { at: Date.now(), reviews })
|
|
113
|
+
return reviews
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const when = (time: unknown) => typeof time === 'number' ? time : typeof time === 'string' ? Date.parse(time) : Number.NaN
|
|
117
|
+
// The reviews `answer` makes that count, by the users `reviewers` names,
|
|
118
|
+
// each dependency's of each hash its integrity names, as of the time it
|
|
119
|
+
// tells, else its user's latest comment's, as last written or edited.
|
|
120
|
+
function counted(answer: CommentReview[], read: Authored[], reviewers: Map<string, string>): PublishedReview[] {
|
|
121
|
+
const latest = new Map<string, number>()
|
|
122
|
+
for (const { comment } of read) {
|
|
123
|
+
const login = comment.author?.toLowerCase()
|
|
124
|
+
if (login) latest.set(login, Math.max(latest.get(login) ?? 0, when(comment.createdAt) || 0, when(comment.editedAt) || 0))
|
|
125
|
+
}
|
|
126
|
+
return answer.flatMap(review => {
|
|
127
|
+
const login = typeof review?.user === 'string' ? review.user.toLowerCase() : ''
|
|
128
|
+
const reviewer = reviewers.get(login)
|
|
129
|
+
if (!reviewer || !latest.has(login) || !Array.isArray(review.dependencies)) return []
|
|
130
|
+
const time = Number.isFinite(when(review.time)) ? when(review.time) : latest.get(login)!
|
|
131
|
+
return review.dependencies.flatMap(dependency => {
|
|
132
|
+
const { name, version, integrity, patchHash, status } = dependency ?? {}
|
|
133
|
+
if (typeof name !== 'string' || typeof version !== 'string' || typeof integrity !== 'string' || !VERDICTS.has(status)) return []
|
|
134
|
+
return integrity.split(/\s+/u).filter(Boolean).map(hash => ({ package: name, version, integrity: hash, ...typeof patchHash === 'string' && patchHash ? { patchHash } : {},
|
|
135
|
+
basePackage: null, baseVersion: null, reviewer, status: status as ReviewVerdict, full: true, time }))
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The reviews pull request `number`'s comments make, as the host's parser
|
|
141
|
+
// reads them, of the versions still wanting one, none where it isn't open
|
|
142
|
+
// (the parser then not asked); `reviewers` the app's users
|
|
143
|
+
// the accounts are, by their logins in lower case, of those whose reviews
|
|
144
|
+
// count for the repository.
|
|
145
|
+
export function commentReviews({ fullName, number, token, parse, reviewers, fetchImpl = globalThis.fetch }: {
|
|
146
|
+
fullName: string; number: number; token: string; parse: NonNullable<ReviewedDependencies['parseComments']>
|
|
147
|
+
reviewers: (accounts: { login: string; githubId: number | null }[]) => Promise<Map<string, string>>; fetchImpl?: typeof fetch
|
|
148
|
+
}): CommentReviews {
|
|
149
|
+
return async wanting => {
|
|
150
|
+
try {
|
|
151
|
+
const { open, read } = await pullComments(fullName, token, number, fetchImpl)
|
|
152
|
+
if (!open) return { reviews: [], failed: false }
|
|
153
|
+
const accounts = new Map(read.flatMap(({ account }) => account.login ? [[account.login.toLowerCase(), { login: account.login, githubId: account.id }]] : []))
|
|
154
|
+
const answer = await parsed(parse, { repository: fullName, number, comments: read.map(({ comment }) => comment), dependencies: wanting })
|
|
155
|
+
return { reviews: counted(answer, read, await reviewers([...accounts.values()])), failed: false }
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.warn('reviewed dependencies: comments of', `${fullName}#${number}`, 'not read:', error instanceof Error ? error.message : error)
|
|
158
|
+
return { reviews: [], failed: true }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// The package versions a pull request's lockfiles lock that they didn't
|
|
2
|
+
// before it, as the code viewer's Pull requests tab shows them on an open
|
|
3
|
+
// one's tile and its popup on any (GET
|
|
4
|
+
// /api/admin/repositories/pull-dependencies): read only where it changes a
|
|
5
|
+
// lockfile of npm's ecosystem that @preventive/lockfile reads
|
|
6
|
+
// (package-lock.json and npm-shrinkwrap.json of `lockfileVersion: 3`,
|
|
7
|
+
// pnpm-lock.yaml of pnpm 9 to 12 and Yarn 1's yarn.lock, locked-packages.ts),
|
|
8
|
+
// and Bun's, which it doesn't (bun.lock and bun.lockb, its directory told
|
|
9
|
+
// `lockfile-unsupported`); none under a node_modules nor a test's fixtures
|
|
10
|
+
// (isTestFixture), which no install takes. Its files are GitHub's comparison of its base with its head
|
|
11
|
+
// (GET /repos/:owner/:repo/compare/:base...:head), which names where its
|
|
12
|
+
// branch left its base, its merge base, as GitHub's own Files changed
|
|
13
|
+
// compares from it, and lists as many as a commit's files are read
|
|
14
|
+
// (repository-changes.ts MAX_FILES, 300, the most it lists); one changing as
|
|
15
|
+
// many, from its own list of them, a page at a time, as many as PULL_FILES.
|
|
16
|
+
// Each directory's lockfiles it changes are read at its merge base and at its
|
|
17
|
+
// head, a lockfile replaced by another, Yarn's by pnpm's say, read as the
|
|
18
|
+
// one it replaced; a version new to a directory is one its lockfiles lock at
|
|
19
|
+
// the head that none of them locked at the merge base, from where it is
|
|
20
|
+
// installed: a package new to it, or a version of one they locked others of,
|
|
21
|
+
// a bump's, told with those (`was`). What a pair of commits holds never
|
|
22
|
+
// changes, so what is told of one is kept by them (`kept`), and each
|
|
23
|
+
// lockfile read is kept in this instance's memory by its commit
|
|
24
|
+
// (`lockfileReads`): the pull requests branching off the same commit, as a
|
|
25
|
+
// bot's do, read their base's once.
|
|
26
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
27
|
+
import { GithubApiError, githubJson } from './github-app.ts'
|
|
28
|
+
import { upstreamGithub } from './github-errors.ts'
|
|
29
|
+
import { compareVersions, isExactVersion } from '@preventive/upstream/semver.js'
|
|
30
|
+
import { type LockedPackage, isTestFixture, lockedPackages } from './locked-packages.ts'
|
|
31
|
+
import { type Lru, lru } from './lru.ts'
|
|
32
|
+
import { type ChangedFile, commitChanges } from './repository-changes.ts'
|
|
33
|
+
|
|
34
|
+
// The files of a pull request read from its own list past those compared,
|
|
35
|
+
// FILES_PAGE a page, as many as GitHub lists (3,000).
|
|
36
|
+
const FILES_PAGE = 100
|
|
37
|
+
export const PULL_FILES = 3000
|
|
38
|
+
// The directories whose lockfiles are read, those past them left out
|
|
39
|
+
// (`cut`), and the packages told new of each, those past them counted.
|
|
40
|
+
export const MAX_DIRECTORIES = 20
|
|
41
|
+
export const MAX_ADDED = 200
|
|
42
|
+
// The versions a directory's lockfiles lock at the head kept with what they
|
|
43
|
+
// add, as many as MAX_LOCKED, those past them counted, for those not new to
|
|
44
|
+
// be reviewed as well (pull-dependency-reviews.ts).
|
|
45
|
+
export const MAX_LOCKED = 20_000
|
|
46
|
+
// Lockfiles read at once.
|
|
47
|
+
const READ_PARALLEL = 4
|
|
48
|
+
// The rules what is told was told by, kept with it (http.ts
|
|
49
|
+
// pullDependencyKept): one told by others is told again.
|
|
50
|
+
export const PULL_DEPENDENCIES_READ = 3
|
|
51
|
+
const SHA = /^[a-f\d]{40}$/u
|
|
52
|
+
|
|
53
|
+
type Format = 'npm' | 'pnpm' | 'yarn' | 'bun'
|
|
54
|
+
|
|
55
|
+
// The format of the lockfile of npm's ecosystem at `path`, as
|
|
56
|
+
// findRepoLockfiles names it (with pnpm's of a branch,
|
|
57
|
+
// pnpm-lock.<branch>.yaml, and of before pnpm 3, shrinkwrap.yaml, which its
|
|
58
|
+
// reader refuses); null for another file, and for one under a node_modules or
|
|
59
|
+
// of a test's fixtures. Bun's alone isn't read.
|
|
60
|
+
export function lockfileFormat(path: string): Format | null {
|
|
61
|
+
const dirs = path.split('/')
|
|
62
|
+
const name = dirs.pop() ?? ''
|
|
63
|
+
if (dirs.includes('node_modules') || isTestFixture(path)) return null
|
|
64
|
+
if (name === 'package-lock.json' || name === 'npm-shrinkwrap.json') return 'npm'
|
|
65
|
+
if (/^pnpm-lock(?:\..+)?\.yaml$/u.test(name) || name === 'shrinkwrap.yaml') return 'pnpm'
|
|
66
|
+
if (name === 'bun.lock' || name === 'bun.lockb') return 'bun'
|
|
67
|
+
return name === 'yarn.lock' ? 'yarn' : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Whether a pull request changing `paths` changes a lockfile read here: null
|
|
71
|
+
// where none of them is one but they are fewer than it changes
|
|
72
|
+
// (`changed`), which it may yet.
|
|
73
|
+
export function touchesLockfile(paths: string[], changed: number): boolean | null {
|
|
74
|
+
if (paths.some(path => lockfileFormat(path) !== null)) return true
|
|
75
|
+
return changed > paths.length ? null : false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A package version new to a directory, as its lockfile locks it, with its
|
|
79
|
+
// integrity, which its reviews are held to; of a package its lockfiles locked
|
|
80
|
+
// before, the versions of it they did (`was`, the highest first), none of one
|
|
81
|
+
// new to it.
|
|
82
|
+
export type AddedPackage = LockedPackage & { was?: string[] }
|
|
83
|
+
// A directory's lockfiles the pull request changes (`paths`, those it adds,
|
|
84
|
+
// modifies, removes or renames, by either name): the package versions they
|
|
85
|
+
// lock new to it, by name and version (as many as MAX_LOCKED, told as many
|
|
86
|
+
// as MAX_ADDED, `more` past them), and all they lock (`locked`, as many as MAX_LOCKED, how many more
|
|
87
|
+
// `moreLocked`); or why they couldn't be told, a lockfile's reader refusing one
|
|
88
|
+
// (`lockfile-unreadable`, with what it said), one of a format not read
|
|
89
|
+
// (`lockfile-unsupported`, Bun's) or GitHub having none at a commit compared.
|
|
90
|
+
export type DirectoryDependencies = { paths: string[] } & ({ added: AddedPackage[]; more: number; locked: LockedPackage[]; moreLocked: number }
|
|
91
|
+
| { error: string; detail?: string })
|
|
92
|
+
// What a pull request's lockfiles add: the commits compared (`base`, its
|
|
93
|
+
// merge base, and `head`); each directory's, of those it changes a lockfile
|
|
94
|
+
// in; and `cut` where it changes more files than GitHub lists, or lockfiles
|
|
95
|
+
// in more directories than are read, with, where only those cut it and they
|
|
96
|
+
// are few enough, the paths of those directories' lockfiles (`omitted`), for
|
|
97
|
+
// a review holding to none of them to fail in nothing.
|
|
98
|
+
export interface PullDependencies { base: string; head: string; lockfiles: DirectoryDependencies[]; cut: boolean; omitted?: string[] }
|
|
99
|
+
const MAX_OMITTED = 1000
|
|
100
|
+
|
|
101
|
+
// What is told of a pull request, by the commits it is of, `${base}...${head}`.
|
|
102
|
+
export interface PullDependencyStore {
|
|
103
|
+
read(key: string): Promise<PullDependencies | null>
|
|
104
|
+
write(key: string, value: PullDependencies): Promise<void>
|
|
105
|
+
}
|
|
106
|
+
|
|
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
|
+
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>>()
|
|
114
|
+
|
|
115
|
+
const api = (fullName: string) => `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
|
|
116
|
+
|
|
117
|
+
// The pull request's base's and head's commits, as GitHub has them now: its
|
|
118
|
+
// base's moving on as it does, its head's with each push, both kept once it
|
|
119
|
+
// is merged or closed.
|
|
120
|
+
async function pullCommits(fullName: string, token: string, number: number, fetchImpl: typeof fetch) {
|
|
121
|
+
const pull = await githubJson(`${api(fullName)}/pulls/${number}`, token, fetchImpl) as
|
|
122
|
+
{ number?: unknown; base?: { sha?: unknown; repo?: { full_name?: unknown } | null }; head?: { sha?: unknown } } | null
|
|
123
|
+
const base = pull?.base?.sha, head = pull?.head?.sha, repo = pull?.base?.repo?.full_name
|
|
124
|
+
if (pull?.number !== number || typeof base !== 'string' || !SHA.test(base) || typeof head !== 'string' || !SHA.test(head)
|
|
125
|
+
|| typeof repo !== 'string' || repo.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(502, 'github-malformed')
|
|
126
|
+
return { base, head }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The pull request's merge base and the files it changes from it: as GitHub
|
|
130
|
+
// compares its base with its head, else, where that lists as many as it lists
|
|
131
|
+
// 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) {
|
|
134
|
+
const compared = await githubJson(`${api(fullName)}/compare/${base}...${head}?per_page=1`, token, fetchImpl) as { merge_base_commit?: { sha?: unknown } } | null
|
|
135
|
+
const mergeBase = compared?.merge_base_commit?.sha
|
|
136
|
+
if (typeof mergeBase !== 'string' || !SHA.test(mergeBase)) throw new GithubApiError(502, 'github-malformed')
|
|
137
|
+
const { files, more } = commitChanges(compared)
|
|
138
|
+
if (!more) return { mergeBase, files, cut: false }
|
|
139
|
+
const listed: ChangedFile[] = []
|
|
140
|
+
for (let page = 1; listed.length < PULL_FILES; page++) {
|
|
141
|
+
const data = await githubJson(`${api(fullName)}/pulls/${number}/files?per_page=${FILES_PAGE}&page=${page}`, token, fetchImpl)
|
|
142
|
+
if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
|
|
143
|
+
listed.push(...commitChanges({ files: data }).files)
|
|
144
|
+
if (data.length < FILES_PAGE) return { mergeBase, files: listed, cut: false }
|
|
145
|
+
}
|
|
146
|
+
return { mergeBase, files: listed, cut: true }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The lockfiles read here a pull request changes (`files`), by directory:
|
|
150
|
+
// each as it was (`before`, one modified or removed, or a rename's path
|
|
151
|
+
// before) and as it is (`after`, one added or modified, or a rename's path
|
|
152
|
+
// now), and every path of them (`paths`), as many directories as
|
|
153
|
+
// MAX_DIRECTORIES.
|
|
154
|
+
function lockfilesChanged(files: ChangedFile[]) {
|
|
155
|
+
type Directory = { before: { path: string; format: Format }[]; after: { path: string; format: Format }[]; paths: Set<string> }
|
|
156
|
+
const directories = new Map<string, Directory>()
|
|
157
|
+
const take = (side: 'before' | 'after', path: string | undefined) => {
|
|
158
|
+
const format = path ? lockfileFormat(path) : null
|
|
159
|
+
if (!path || !format) return
|
|
160
|
+
const dir = path.slice(0, Math.max(path.lastIndexOf('/'), 0))
|
|
161
|
+
const directory = directories.get(dir) ?? directories.set(dir, { before: [], after: [], paths: new Set() }).get(dir)!
|
|
162
|
+
directory[side].push({ path, format })
|
|
163
|
+
directory.paths.add(path)
|
|
164
|
+
}
|
|
165
|
+
for (const [path, status, from] of files) {
|
|
166
|
+
take('before', status === 'R' ? from : status === 'A' ? undefined : path)
|
|
167
|
+
take('after', status === 'D' ? undefined : path)
|
|
168
|
+
}
|
|
169
|
+
const all = [...directories.entries()].toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
170
|
+
return { directories: all.slice(0, MAX_DIRECTORIES).map(([, directory]) => directory), cut: all.length > MAX_DIRECTORIES,
|
|
171
|
+
omitted: all.slice(MAX_DIRECTORIES).flatMap(([, directory]) => [...directory.paths]).toSorted() }
|
|
172
|
+
}
|
|
173
|
+
|
|
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))
|
|
190
|
+
lockfilesReading.set(shared, reading)
|
|
191
|
+
return reading
|
|
192
|
+
}
|
|
193
|
+
async function readOf(fullName: string, token: string, ref: string, { path, format }: { path: string; format: Format }, key: string): Promise<LockfileRead> {
|
|
194
|
+
let text: string
|
|
195
|
+
try {
|
|
196
|
+
text = await upstreamGithub(() => createClient({ token, userAgent: 'deepview-triage' }).getRepoFile({ repo: fullName, path, ref }))
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (err instanceof GithubApiError && err.message === 'github-not-found') return { error: 'github-not-found' }
|
|
199
|
+
throw err
|
|
200
|
+
}
|
|
201
|
+
let read: LockfileRead
|
|
202
|
+
try { read = { packages: lockedPackages(format, text, { review: true }) } } catch (error) {
|
|
203
|
+
read = { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
|
|
204
|
+
}
|
|
205
|
+
lockfileReads.set(key, read)
|
|
206
|
+
return read
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Runs `run` for each of `items`, as many at once as `parallel`, each's
|
|
210
|
+
// result in its place.
|
|
211
|
+
export async function eachOf<T, R>(items: T[], parallel: number, run: (item: T) => Promise<R>): Promise<R[]> {
|
|
212
|
+
const results: R[] = []
|
|
213
|
+
let next = 0
|
|
214
|
+
await Promise.all(Array.from({ length: Math.min(parallel, items.length) }, async () => {
|
|
215
|
+
while (next < items.length) {
|
|
216
|
+
const at = next++
|
|
217
|
+
results[at] = await run(items[at]!)
|
|
218
|
+
}
|
|
219
|
+
}))
|
|
220
|
+
return results
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Versions by semver, the highest first, any semver can't read after them.
|
|
224
|
+
function byVersion(a: string, b: string) {
|
|
225
|
+
const [exactA, exactB] = [isExactVersion(a), isExactVersion(b)]
|
|
226
|
+
if (exactA && exactB) return compareVersions(b, a)
|
|
227
|
+
if (exactA === exactB) return a < b ? -1 : a > b ? 1 : 0
|
|
228
|
+
return exactA ? -1 : 1
|
|
229
|
+
}
|
|
230
|
+
const byName = (a: AddedPackage, b: AddedPackage) => a.name < b.name ? -1 : a.name > b.name ? 1 : -byVersion(a.version ?? '', b.version ?? '')
|
|
231
|
+
// A package as it is installed: its name and version, where from, and its
|
|
232
|
+
// patch, a patched version's new where its patch is; and as it is locked,
|
|
233
|
+
// with what its reviews are held to too.
|
|
234
|
+
export const lockedAs = (pkg: LockedPackage) => `${pkg.source}\0${pkg.name}@${pkg.version ?? ''}\0${pkg.from ?? ''}\0${pkg.patchHash ?? ''}`
|
|
235
|
+
export const lockedWith = (pkg: LockedPackage) => `${lockedAs(pkg)}\0${pkg.integrity ?? ''}\0${pkg.shasum ?? ''}\0${pkg.lockfile ?? ''}`
|
|
236
|
+
|
|
237
|
+
// What a directory's lockfiles add, from each read before and after: the
|
|
238
|
+
// package versions after none before has, each once, by name and version,
|
|
239
|
+
// each with the versions of its name before, those past MAX_ADDED counted
|
|
240
|
+
// (`more`), not told but reviewed as they are; and every one after, each
|
|
241
|
+
// integrity it is locked with (`locked`), as many as MAX_LOCKED; or the first
|
|
242
|
+
// failure among them.
|
|
243
|
+
function addedIn(paths: string[], before: LockfileRead[], after: LockfileRead[]): DirectoryDependencies {
|
|
244
|
+
const failed = [...before, ...after].find(read => 'error' in read)
|
|
245
|
+
if (failed && 'error' in failed) return { paths, error: failed.error, ...failed.detail ? { detail: failed.detail } : {} }
|
|
246
|
+
const known = new Set<string>()
|
|
247
|
+
const versions = new Map<string, Set<string>>()
|
|
248
|
+
for (const pkg of before.flatMap(read => 'packages' in read ? read.packages : [])) {
|
|
249
|
+
known.add(lockedAs(pkg))
|
|
250
|
+
const of = versions.get(pkg.name) ?? versions.set(pkg.name, new Set()).get(pkg.name)!
|
|
251
|
+
if (pkg.version) of.add(pkg.version)
|
|
252
|
+
}
|
|
253
|
+
const added = new Map<string, AddedPackage>()
|
|
254
|
+
const locked = new Map<string, LockedPackage>()
|
|
255
|
+
for (const read of after) {
|
|
256
|
+
for (const pkg of 'packages' in read ? read.packages : []) {
|
|
257
|
+
const key = lockedAs(pkg)
|
|
258
|
+
const held = locked.get(lockedWith(pkg))
|
|
259
|
+
if (!held) locked.set(lockedWith(pkg), { ...pkg })
|
|
260
|
+
else if (held.dev && !pkg.dev) delete held.dev
|
|
261
|
+
if (known.has(key)) continue
|
|
262
|
+
const had = added.get(key)
|
|
263
|
+
const was = versions.get(pkg.name)
|
|
264
|
+
if (!had) added.set(key, { ...pkg, ...was ? { was: [...was].toSorted(byVersion) } : {} })
|
|
265
|
+
else if (had.dev && !pkg.dev) delete had.dev
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const all = [...added.values()].toSorted(byName)
|
|
269
|
+
return { paths, added: all.slice(0, MAX_LOCKED), more: Math.max(0, all.length - MAX_ADDED),
|
|
270
|
+
locked: [...locked.values()].slice(0, MAX_LOCKED), moreLocked: Math.max(0, locked.size - MAX_LOCKED) }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// What pull request `number` adds to its lockfiles' packages (PullDependencies),
|
|
274
|
+
// kept by its base's and head's commits, else read of GitHub and kept; none
|
|
275
|
+
// where it changes no lockfile read here.
|
|
276
|
+
export async function pullDependencies(fullName: string, token: string, number: number, kept: PullDependencyStore,
|
|
277
|
+
fetchImpl: typeof fetch = globalThis.fetch): Promise<PullDependencies> {
|
|
278
|
+
return (await pullDependenciesAt(fullName, token, number, kept, fetchImpl)).told
|
|
279
|
+
}
|
|
280
|
+
// The same, with the commits it is kept by (`commits`), its base's and
|
|
281
|
+
// head's, by which `kept` reads it.
|
|
282
|
+
export async function pullDependenciesAt(fullName: string, token: string, number: number, kept: PullDependencyStore,
|
|
283
|
+
fetchImpl: typeof fetch = globalThis.fetch): Promise<{ commits: string; told: PullDependencies }> {
|
|
284
|
+
const { base, head } = await pullCommits(fullName, token, number, fetchImpl)
|
|
285
|
+
const key = `${base}...${head}`
|
|
286
|
+
const held = await kept.read(key)
|
|
287
|
+
if (held) return { commits: key, told: held }
|
|
288
|
+
const { mergeBase, files, cut: filesCut } = await pullFiles(fullName, token, number, base, head, fetchImpl)
|
|
289
|
+
const { directories, cut, omitted } = lockfilesChanged(files)
|
|
290
|
+
const reads = directories.flatMap(({ before, after }) => [...before.map(lockfile => [mergeBase, lockfile] as const), ...after.map(lockfile => [head, lockfile] as const)])
|
|
291
|
+
const read = await eachOf(reads, READ_PARALLEL, ([ref, lockfile]) => readLockfile(fullName, token, ref, lockfile))
|
|
292
|
+
let at = 0
|
|
293
|
+
const lockfiles = directories.map(({ before, after, paths }) => {
|
|
294
|
+
const readBefore = read.slice(at, at += before.length)
|
|
295
|
+
return addedIn([...paths].toSorted(), readBefore, read.slice(at, at += after.length))
|
|
296
|
+
})
|
|
297
|
+
const told = { base: mergeBase, head, lockfiles, cut: filesCut || cut, ...cut && !filesCut && omitted.length <= MAX_OMITTED ? { omitted } : {} }
|
|
298
|
+
await kept.write(key, told)
|
|
299
|
+
return { commits: key, told }
|
|
300
|
+
}
|