@preventive/triage 1.0.0-alpha.43 → 1.0.0-alpha.45
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/common/bundle-compare-diff.js +2 -167
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-sources.js +20 -1
- 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/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 +91 -10
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +11 -0
- package/common/workspace-app.js +19 -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-5DHL5NG3.js +2 -0
- package/out/chunk-6HNXNIQY.js +52 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-AFLZH7WG.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HMAJDVXN.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 -0
- package/out/chunk-NPW52BM2.js +17 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-NV7ES4EW.js +1 -0
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PTGDYLNS.js +36 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-Q6MVYGYV.js +3 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-QRD6GIG5.js +229 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WI7Z5A2T.js +6 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/chunk-XVBEOGOS.js +3 -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 +31 -31
- 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 +55 -6
- package/risk/actions/findings.js +156 -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 +165 -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 +68 -1
- package/risk/index.js +6 -2
- package/risk/npm/assess.js +13 -8
- package/risk/npm/compare.js +34 -10
- package/risk/npm/dependencies.js +11 -0
- package/risk/npm/files.js +413 -2
- package/risk/npm/tarball.js +56 -73
- 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/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +62 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/db-methods.ts +29 -10
- package/server-managed/db-neon.ts +30 -2
- package/server-managed/db-schema.ts +9 -2
- package/server-managed/db.ts +7 -0
- 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 +65 -15
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +23 -9
- package/server-managed/github-errors.ts +2 -0
- 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-users.ts +30 -0
- package/server-managed/http.ts +1219 -81
- package/server-managed/index.ts +6 -2
- package/server-managed/locked-packages.ts +48 -16
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +173 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +51 -4
- package/server-managed/npm-loads.ts +20 -13
- package/server-managed/npm-packages.ts +24 -64
- package/server-managed/pretty-print.ts +14 -9
- package/server-managed/public-workspace.ts +26 -8
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +280 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/repository-actions.ts +209 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +54 -13
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +135 -0
- package/server-managed/repository-history.ts +445 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +432 -0
- package/server-managed/repository-risk-read.ts +165 -0
- package/server-managed/repository-risk.ts +116 -106
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +9 -5
- package/server-managed/sql.ts +1 -1
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/team-app.ts +54 -14
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +11 -0
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4F6JSF57.js +0 -3
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-7RU5EDRO.js +0 -226
- package/out/chunk-A7M4GV7M.js +0 -1
- package/out/chunk-CLPKNNC7.js +0 -51
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- 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-LAAB5SZT.js +0 -2
- package/out/chunk-NWZ4PVPN.js +0 -1
- package/out/chunk-OSIMGFUF.js +0 -1
- package/out/chunk-RFZQQXSG.js +0 -17
- package/out/chunk-RSHMVYNS.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UXNUAIIC.js +0 -33
- 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-VQJCXGIY.js +0 -7
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-WZDTZE64.js +0 -1
- package/out/chunk-XOCAPDCM.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-YVZH5SHP.js +0 -1
- package/out/chunk-ZBBOBQ43.js +0 -1
- package/out/chunk-ZMQQCGF6.js +0 -2
|
@@ -14,6 +14,7 @@ import { Buffer } from 'node:buffer'
|
|
|
14
14
|
import { createHash } from 'node:crypto'
|
|
15
15
|
import { rm } from 'node:fs/promises'
|
|
16
16
|
import { resolve } from 'node:path'
|
|
17
|
+
import { ArchiveError, type Entry, unpack } from '@preventive/archive/tar.js'
|
|
17
18
|
import { isExactVersion } from '@preventive/upstream/semver.js'
|
|
18
19
|
import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
|
|
19
20
|
import type { Role } from '../common/managed/roles.ts'
|
|
@@ -33,6 +34,9 @@ const MAX_DOCUMENT_BYTES = 4 * PACKUMENT_BYTES
|
|
|
33
34
|
// stream holding them is bounded in npm-loads.ts.
|
|
34
35
|
export const MAX_NPM_PACKAGE_BYTES = 64 * 1024 * 1024
|
|
35
36
|
export const MAX_NPM_PACKAGE_FILES = 20_000
|
|
37
|
+
// The entries its tar stream may hold, read before any is counted as a file:
|
|
38
|
+
// a directory's too, as a GitHub tree's tarball has one for each, npm's none.
|
|
39
|
+
export const MAX_NPM_TAR_ENTRIES = 2 * MAX_NPM_PACKAGE_FILES
|
|
36
40
|
// What the files may take as the viewer's JSON. Escaping grows a text up to
|
|
37
41
|
// six times (a control character becomes `\u0001`), so it is counted before
|
|
38
42
|
// anything is serialized.
|
|
@@ -277,73 +281,29 @@ export const npmPublishTime = (name: string, version: string): string | null =>
|
|
|
277
281
|
|
|
278
282
|
export interface NpmPackageFile { path: string; bytes: Uint8Array }
|
|
279
283
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
284
|
+
// The regular files of a tar stream, by package path: npm drops the first
|
|
285
|
+
// component (`package/`, or whatever the archive used), and a file at the top
|
|
286
|
+
// is not extracted. The stream is read by @preventive/archive, which refuses
|
|
287
|
+
// one malformed, a name or a symlink that would climb out of it -- a symlink
|
|
288
|
+
// past `symlinksAbove` levels above it, none unless given -- and a name
|
|
289
|
+
// repeated as another entry, and gives up past MAX_NPM_TAR_ENTRIES before
|
|
290
|
+
// reading on; a file under another top directory at the same path replaces
|
|
291
|
+
// an earlier one, as extraction would. Directories, links and devices hold no
|
|
292
|
+
// file content and are skipped.
|
|
293
|
+
export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0 }: { symlinksAbove?: number } = {}): NpmPackageFile[] {
|
|
294
|
+
let entries: Entry[]
|
|
295
|
+
try { entries = unpack(tar, { maxEntries: MAX_NPM_TAR_ENTRIES, symlinksAbove }) }
|
|
296
|
+
catch (err) {
|
|
297
|
+
if (err instanceof ArchiveError) throw err.limited ? new NpmPackageError(413, 'package-too-large') : new NpmPackageError(422, 'bad-tarball')
|
|
298
|
+
throw err
|
|
292
299
|
}
|
|
293
|
-
const text = textOf(bytes, offset, 12).trim()
|
|
294
|
-
return /^[0-7]+$/u.test(text) ? Number.parseInt(text, 8) : Number.NaN
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// The path a package's file is installed at: npm drops the first component
|
|
298
|
-
// (`package/`, or whatever the archive used), and a path that would escape the
|
|
299
|
-
// package, or names nothing, is not extracted.
|
|
300
|
-
function packagePath(raw: string): string | null {
|
|
301
|
-
const parts = raw.split('/').slice(1).filter(part => part !== '' && part !== '.')
|
|
302
|
-
return parts.length === 0 || parts.includes('..') ? null : parts.join('/')
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// A pax header's path. Each record is `<length> <key>=<value>\n`, its length
|
|
306
|
-
// in bytes, so records are walked as bytes and only their values decoded.
|
|
307
|
-
function paxPath(data: Uint8Array): string | null {
|
|
308
|
-
let path = null
|
|
309
|
-
let at = 0
|
|
310
|
-
while (at < data.length) {
|
|
311
|
-
const space = data.indexOf(0x20, at)
|
|
312
|
-
const length = Number.parseInt(Buffer.from(data.subarray(at, space)).toString('latin1'), 10)
|
|
313
|
-
if (space === -1 || !Number.isSafeInteger(length) || length <= 0) break
|
|
314
|
-
const record = Buffer.from(data.subarray(space + 1, at + length - 1)).toString('utf8')
|
|
315
|
-
if (record.startsWith('path=')) path = record.slice(5)
|
|
316
|
-
at += length
|
|
317
|
-
}
|
|
318
|
-
return path
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// The regular files of a tar stream, by package path, a later entry
|
|
322
|
-
// replacing an earlier one as extraction would. Directories, links and
|
|
323
|
-
// devices hold no file content and are skipped.
|
|
324
|
-
export function readNpmTar(tar: Uint8Array): NpmPackageFile[] {
|
|
325
300
|
const files = new Map<string, Uint8Array>()
|
|
326
301
|
let total = 0
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
const size = sizeOf(header, 124)
|
|
333
|
-
const type = String.fromCodePoint(header[156]!)
|
|
334
|
-
const start = offset + 512
|
|
335
|
-
if (!Number.isSafeInteger(size) || size < 0 || start + size > tar.length) throw new NpmPackageError(422, 'bad-tarball')
|
|
336
|
-
const data = tar.subarray(start, start + size)
|
|
337
|
-
offset = start + Math.ceil(size / 512) * 512
|
|
338
|
-
if (type === 'x' || type === 'L') { longName = type === 'x' ? paxPath(data) : textOf(data, 0, data.length); continue }
|
|
339
|
-
if (type === 'g' || type === 'K') continue
|
|
340
|
-
const prefix = textOf(header, 257, 6) === 'ustar' ? textOf(header, 345, 155) : ''
|
|
341
|
-
const name = longName ?? (prefix ? `${prefix}/${textOf(header, 0, 100)}` : textOf(header, 0, 100))
|
|
342
|
-
longName = null
|
|
343
|
-
if (!['0', '\0', '7'].includes(type)) continue
|
|
344
|
-
const path = packagePath(name)
|
|
345
|
-
if (path === null) continue
|
|
346
|
-
total += size - (files.get(path)?.length ?? 0)
|
|
302
|
+
for (const { name, type, data } of entries) {
|
|
303
|
+
const slash = name.indexOf('/')
|
|
304
|
+
if ((type !== 'file' && type !== 'contiguous-file') || slash === -1) continue
|
|
305
|
+
const path = name.slice(slash + 1)
|
|
306
|
+
total += data.length - (files.get(path)?.length ?? 0)
|
|
347
307
|
files.set(path, data)
|
|
348
308
|
if (files.size > MAX_NPM_PACKAGE_FILES || total > MAX_NPM_PACKAGE_BYTES) throw new NpmPackageError(413, 'package-too-large')
|
|
349
309
|
}
|
|
@@ -250,18 +250,21 @@ export function stalePrettyRemover(npm: CacheStorage): (signal?: AbortSignal) =>
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
// A bundle's details, from its bytes in `store`: bundles are read one at a
|
|
254
|
+
// time, as reading one decodes all of it.
|
|
255
|
+
function detailsReader(store: BundleStore) {
|
|
256
|
+
const enqueue = serial()
|
|
257
|
+
return (record: ManagedBundle) => enqueue(async () => {
|
|
258
|
+
const details = await readBundleDetails(record, store)
|
|
259
|
+
if (!details) throw new PrettyError(422, 'bundle-unavailable')
|
|
260
|
+
return details
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
253
264
|
export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage, db: Pick<ManagedDb, 'getBundle'>, store: BundleStore) {
|
|
254
265
|
// Copies being made; readers asking for one meanwhile share it.
|
|
255
266
|
const shared = sharing<Buffer>()
|
|
256
|
-
|
|
257
|
-
const enqueue = serial()
|
|
258
|
-
function bundleDetails(record: ManagedBundle) {
|
|
259
|
-
return enqueue(async () => {
|
|
260
|
-
const details = await readBundleDetails(record, store)
|
|
261
|
-
if (!details) throw new PrettyError(422, 'bundle-unavailable')
|
|
262
|
-
return details
|
|
263
|
-
})
|
|
264
|
-
}
|
|
267
|
+
const bundleDetails = detailsReader(store)
|
|
265
268
|
// A bundle's file pretty-printed from the text `read` reads, and kept in
|
|
266
269
|
// its cache.
|
|
267
270
|
function bundleCopy(record: ManagedBundle, request: PrettyRequest, read: () => Promise<string>): Promise<Buffer> {
|
|
@@ -341,6 +344,8 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
|
|
|
341
344
|
made: async (request, text) => opened(await bundleCopy(record, request, () => Promise.resolve(text))) })
|
|
342
345
|
return [await sideOf(base, 'base'), await sideOf(other, 'other')]
|
|
343
346
|
},
|
|
347
|
+
// A bundle's details, read in its turn, as Compare pairs them (comparison.ts).
|
|
348
|
+
bundleDetails,
|
|
344
349
|
async npmSides(base: NpmVersionDocument, other: NpmVersionDocument): Promise<[ComparedSide, ComparedSide]> {
|
|
345
350
|
const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc)), packageDirs: null,
|
|
346
351
|
kept: request => (doc.private ? Promise.resolve(null) : kept(() => npm.open(prettyFile(request)))),
|
|
@@ -17,7 +17,8 @@ import { serveTeamFeed } from './team-feed.ts'
|
|
|
17
17
|
import { type ComparedSide, PrettyError, compared, prettyRequest, prettyText } from './pretty-print.ts'
|
|
18
18
|
import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
|
|
19
19
|
import { typeOnlyFiles } from './type-only.ts'
|
|
20
|
-
import {
|
|
20
|
+
import { comparisonBody } from './comparison.ts'
|
|
21
|
+
import { sharedTeamApp, withWorkspaceSummary } from './team-app.ts'
|
|
21
22
|
|
|
22
23
|
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
23
24
|
sendJson(res, status, body, { 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
|
|
@@ -35,7 +36,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
35
36
|
const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
|
|
36
37
|
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
|
|
37
38
|
const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
|
|
38
|
-
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only)$/u.exec(url.pathname)
|
|
39
|
+
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only|comparison)$/u.exec(url.pathname)
|
|
39
40
|
if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
|
|
40
41
|
const tokenHash = hashToken(token)
|
|
41
42
|
const snapshot = await deps.db.getWorkspaceShare(tokenHash)
|
|
@@ -46,8 +47,8 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
46
47
|
}
|
|
47
48
|
const send = async (body: unknown) => { await recheck(); json(res, 200, body) }
|
|
48
49
|
const sendTeam = async () => {
|
|
49
|
-
const [summaries,
|
|
50
|
-
await send({ user: snapshot.user, team: { ...snapshot.team,
|
|
50
|
+
const [summaries, workspace] = await Promise.all([bundleSummaries(snapshot.team.bundles, deps.bundleCache), sharedTeamApp(deps.db, deps.reportStore, snapshot)])
|
|
51
|
+
await send({ user: snapshot.user, team: { ...withWorkspaceSummary(snapshot.team, workspace),
|
|
51
52
|
bundles: snapshot.team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })),
|
|
52
53
|
} })
|
|
53
54
|
await backfillBundleSummaries(snapshot.team.bundles, deps.bundleCache)
|
|
@@ -101,7 +102,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
101
102
|
const bundle = snapshot.bundles.find(item => item.id === bundleRoute![1])
|
|
102
103
|
if (!bundle) { json(res, 404, { error: 'no-bundle' }); return }
|
|
103
104
|
if (bundleRoute![2] === 'advisories' && !snapshot.permissions.security) { json(res, 403, { error: 'security-access-required' }); return }
|
|
104
|
-
if (bundleRoute![2] === 'rename-diff' || bundleRoute![2] === 'type-only' || (bundleRoute![2] === 'pretty' && url.searchParams.has('other'))) {
|
|
105
|
+
if (bundleRoute![2] === 'rename-diff' || bundleRoute![2] === 'type-only' || bundleRoute![2] === 'comparison' || (bundleRoute![2] === 'pretty' && url.searchParams.has('other'))) {
|
|
105
106
|
if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
|
|
106
107
|
await serveCompared(res, deps, bundle, snapshot.bundles.find(item => item.id === url.searchParams.get('other')), bundleRoute![2], url, recheck, stream); return
|
|
107
108
|
}
|
|
@@ -109,11 +110,12 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
// What a bundle is compared with `other`, another of the link's bundles, by:
|
|
112
|
-
// one of their JavaScript files diffed
|
|
113
|
-
// files that changed between them, or the
|
|
114
|
-
// changed in types alone, a line each.
|
|
113
|
+
// their files paired, as one comparison; one of their JavaScript files diffed
|
|
114
|
+
// with renamed names hidden; or the files that changed between them, or the
|
|
115
|
+
// pairs of those, or those that changed in types alone, a line each.
|
|
115
116
|
function serveCompared(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, part: string, url: URL,
|
|
116
117
|
recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
|
|
118
|
+
if (part === 'comparison') return serveComparison(res, deps, bundle, other, url, stream)
|
|
117
119
|
if (part === 'rename-diff' && url.searchParams.has('path')) return serveRenameDiff(res, deps, bundle, other, url, stream)
|
|
118
120
|
return serveChanges(res, deps, bundle, other, recheck, part === 'pretty' ? compared : part === 'type-only' ? typeOnlyFiles : renamedPairs,
|
|
119
121
|
part === 'pretty' ? '' : url.searchParams.get('scope') ?? '')
|
|
@@ -139,6 +141,22 @@ async function serveRenameDiff(res: ServerResponse, deps: ManagedHttpDeps, bundl
|
|
|
139
141
|
await stream(diff, 'br')
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
// A bundle's files paired with `other`'s, another of the link's bundles, as
|
|
145
|
+
// Compare pairs them (comparison.ts), within a reason `scope` where given.
|
|
146
|
+
async function serveComparison(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, url: URL,
|
|
147
|
+
stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
|
|
148
|
+
if (!other) { json(res, 404, { error: 'no-bundle' }); return }
|
|
149
|
+
const { prettyCache } = deps
|
|
150
|
+
if (!prettyCache) { json(res, 503, { error: 'unavailable' }); return }
|
|
151
|
+
let body
|
|
152
|
+
try { body = await comparisonBody(await prettyCache.bundleDetails(bundle), await prettyCache.bundleDetails(other), url.searchParams.get('scope') ?? '') }
|
|
153
|
+
catch (err) {
|
|
154
|
+
if (err instanceof PrettyError) { json(res, err.status, { error: err.message }); return }
|
|
155
|
+
throw err
|
|
156
|
+
}
|
|
157
|
+
await stream(body, 'br')
|
|
158
|
+
}
|
|
159
|
+
|
|
142
160
|
// The files that changed between a bundle and `other`, another of the link's
|
|
143
161
|
// bundles, both sides' pretty-printed (pretty-print.ts compared), or the pairs
|
|
144
162
|
// of them its viewer diffs with renamed names hidden (rename-diff.ts
|
|
@@ -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
|
+
}
|