@preventive/triage 1.0.0-alpha.45 → 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 +5 -1
- package/common/line-diff.js +5 -4
- package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
- package/out/{chunk-AFLZH7WG.js → chunk-4UFAMJAX.js} +1 -1
- package/out/{chunk-HMAJDVXN.js → chunk-7XRR4OFU.js} +1 -1
- package/out/{chunk-Q6MVYGYV.js → chunk-BOIXMUU5.js} +1 -1
- package/out/{chunk-XVBEOGOS.js → chunk-CC4OM6TZ.js} +2 -2
- package/out/chunk-G24A42V6.js +2 -0
- package/out/{chunk-WI7Z5A2T.js → chunk-JKGQ3O7O.js} +1 -1
- package/out/chunk-LE5ZWLG4.js +235 -0
- package/out/chunk-MSION3G4.js +36 -0
- package/out/{chunk-6HNXNIQY.js → chunk-S6GIXWQW.js} +1 -1
- package/out/chunk-V3KWNNMY.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +25 -25
- package/out/compare.js +13 -13
- package/out/dialogs.js +40 -40
- package/out/graph.js +1 -1
- package/out/index.html +6 -6
- package/out/scan.js +2 -2
- package/out/terminal.js +1 -1
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +3 -1
- package/risk/actions/findings.js +14 -9
- package/risk/actions/scripts/shell.js +5 -1
- package/risk/index.d.ts +5 -2
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/bundle-cache.ts +7 -1
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-lockfiles.ts +21 -11
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +37 -9
- package/server-managed/db-neon.ts +61 -21
- 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 +195 -111
- package/server-managed/index.ts +8 -7
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/npm-delta.ts +11 -6
- package/server-managed/npm-kept.ts +32 -2
- package/server-managed/npm-loads.ts +100 -30
- package/server-managed/npm-packages.ts +89 -3
- package/server-managed/npm-risk.ts +4 -1
- package/server-managed/pretty-print.ts +67 -23
- 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 +42 -9
- package/server-managed/repository-actions.ts +15 -6
- package/server-managed/repository-browser.ts +119 -31
- package/server-managed/repository-dependency-reviews.ts +41 -19
- package/server-managed/repository-history.ts +24 -8
- package/server-managed/repository-pulse.ts +8 -1
- 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/sql.ts +5 -2
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-reports.ts +24 -12
- package/server-managed/upstream-cache.ts +12 -0
- package/server.ts +14 -5
- package/out/chunk-5DHL5NG3.js +0 -2
- package/out/chunk-NV7ES4EW.js +0 -1
- package/out/chunk-PTGDYLNS.js +0 -36
- package/out/chunk-QRD6GIG5.js +0 -229
|
@@ -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
|
}
|
|
@@ -109,6 +109,8 @@ export interface PullDependencyStore {
|
|
|
109
109
|
// scan's are (repository-risk.ts riskCaches).
|
|
110
110
|
type LockfileRead = { packages: LockedPackage[] } | { error: string; detail?: string }
|
|
111
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>>()
|
|
112
114
|
|
|
113
115
|
const api = (fullName: string) => `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
|
|
114
116
|
|
|
@@ -169,14 +171,26 @@ function lockfilesChanged(files: ChangedFile[]) {
|
|
|
169
171
|
omitted: all.slice(MAX_DIRECTORIES).flatMap(([, directory]) => [...directory.paths]).toSorted() }
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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}`
|
|
178
186
|
const known = lockfileReads.get(key)
|
|
179
|
-
if (known) return known
|
|
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> {
|
|
180
194
|
let text: string
|
|
181
195
|
try {
|
|
182
196
|
text = await upstreamGithub(() => createClient({ token, userAgent: 'deepview-triage' }).getRepoFile({ repo: fullName, path, ref }))
|
|
@@ -261,10 +275,16 @@ function addedIn(paths: string[], before: LockfileRead[], after: LockfileRead[])
|
|
|
261
275
|
// where it changes no lockfile read here.
|
|
262
276
|
export async function pullDependencies(fullName: string, token: string, number: number, kept: PullDependencyStore,
|
|
263
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 }> {
|
|
264
284
|
const { base, head } = await pullCommits(fullName, token, number, fetchImpl)
|
|
265
285
|
const key = `${base}...${head}`
|
|
266
286
|
const held = await kept.read(key)
|
|
267
|
-
if (held) return held
|
|
287
|
+
if (held) return { commits: key, told: held }
|
|
268
288
|
const { mergeBase, files, cut: filesCut } = await pullFiles(fullName, token, number, base, head, fetchImpl)
|
|
269
289
|
const { directories, cut, omitted } = lockfilesChanged(files)
|
|
270
290
|
const reads = directories.flatMap(({ before, after }) => [...before.map(lockfile => [mergeBase, lockfile] as const), ...after.map(lockfile => [head, lockfile] as const)])
|
|
@@ -276,5 +296,5 @@ export async function pullDependencies(fullName: string, token: string, number:
|
|
|
276
296
|
})
|
|
277
297
|
const told = { base: mergeBase, head, lockfiles, cut: filesCut || cut, ...cut && !filesCut && omitted.length <= MAX_OMITTED ? { omitted } : {} }
|
|
278
298
|
await kept.write(key, told)
|
|
279
|
-
return told
|
|
299
|
+
return { commits: key, told }
|
|
280
300
|
}
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// changes: it is read once, every lockfile there of those read here
|
|
25
25
|
// (pull-dependencies.ts), and kept by the commit (`baselines` in this
|
|
26
26
|
// instance, `kept` beyond it), never stored as reviews.
|
|
27
|
-
import { lockedAt, lockedIn } from './commit-lockfiles.ts'
|
|
27
|
+
import { type GithubAsked, lockedAt, lockedIn } from './commit-lockfiles.ts'
|
|
28
28
|
import { GithubApiError } from './github-app.ts'
|
|
29
29
|
import type { LockedPackage } from './locked-packages.ts'
|
|
30
30
|
import { EVERY_LOCKFILE, type LockfileScope, heldTo, isEvery, reviewedIn } from './lockfile-scope.ts'
|
|
@@ -68,13 +68,13 @@ const baselineKeys = (pkg: LockedPackage) => hashesOf(pkg).map(hash => `${pkg.na
|
|
|
68
68
|
// and kept; with the lockfiles there its reader refuses, isn't one of, or
|
|
69
69
|
// past those read (`unread`). GitHub having none of a lockfile it lists
|
|
70
70
|
// there is a failure of the whole, as of any file, never kept.
|
|
71
|
-
export async function reviewBaseline(fullName: string, token: string, repoId: number, since: string, kept: ReviewBaselineStore): Promise<Baseline> {
|
|
71
|
+
export async function reviewBaseline(fullName: string, token: string, repoId: number, since: string, kept: ReviewBaselineStore, asked?: GithubAsked): Promise<Baseline> {
|
|
72
72
|
const key = `${repoId}\0${since}`
|
|
73
73
|
const known = baselines.get(key)
|
|
74
74
|
if (known) return known
|
|
75
75
|
const stored = await kept.read(key)
|
|
76
76
|
if (stored) { const baseline = { versions: new Set(stored.versions), unread: stored.unread }; baselines.set(key, baseline); return baseline }
|
|
77
|
-
const read = await lockedAt(fullName, token, since)
|
|
77
|
+
const read = await lockedAt(fullName, token, since, () => true, asked)
|
|
78
78
|
const { packages } = lockedIn(read), { unread } = read
|
|
79
79
|
const versions = new Set<string>()
|
|
80
80
|
for (const pkg of packages) if (reviewedAs(pkg) === 'version') for (const held of baselineKeys(pkg)) versions.add(held)
|
|
@@ -298,18 +298,18 @@ export async function reviewedDependencies(told: PullDependencies, state: RepoRe
|
|
|
298
298
|
...unread.length > 0 && !reviewed ? { baselineUnread: unread.slice(0, MAX_UNREVIEWED) } : {}, ...isEvery(scope) ? {} : { lockfiles: scope } } }
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
// The
|
|
302
|
-
// pass, whose reviews are asked: those that can be reviewed, unpatched.
|
|
301
|
+
// The package versions a pull request's lockfiles lock that its reviews here
|
|
302
|
+
// may pass, whose reviews are asked: those that can be reviewed, unpatched.
|
|
303
303
|
export const reviewedPackages = (told: PullDependencies) => told.lockfiles
|
|
304
|
-
.flatMap(dir => 'error' in dir ? [] : dir.locked.filter(pkg => reviewedAs(pkg) === 'version' && !pkg.patchHash).map(pkg => pkg.name))
|
|
304
|
+
.flatMap(dir => 'error' in dir ? [] : dir.locked.filter(pkg => reviewedAs(pkg) === 'version' && !pkg.patchHash).map(pkg => ({ name: pkg.name, version: pkg.version! })))
|
|
305
305
|
|
|
306
306
|
// A repository's baseline as read for a pull request: its versions and the
|
|
307
307
|
// lockfiles there left unread; none where it reviews since no commit; and
|
|
308
308
|
// where GitHub doesn't read it, none and why.
|
|
309
|
-
export async function baselineFor(fullName: string, token: string, repoId: number, state: RepoReviewState, kept: ReviewBaselineStore) {
|
|
309
|
+
export async function baselineFor(fullName: string, token: string, repoId: number, state: RepoReviewState, kept: ReviewBaselineStore, asked?: GithubAsked) {
|
|
310
310
|
if (!state.since) return { baseline: null, unread: [], error: null }
|
|
311
311
|
try {
|
|
312
|
-
const { versions, unread } = await reviewBaseline(fullName, token, repoId, state.since, kept)
|
|
312
|
+
const { versions, unread } = await reviewBaseline(fullName, token, repoId, state.since, kept, asked)
|
|
313
313
|
return { baseline: versions, unread, error: null }
|
|
314
314
|
} catch (err) {
|
|
315
315
|
if (err instanceof GithubApiError && err.status !== 429 && err.status !== 401) return { baseline: null, unread: [], error: err.message }
|
package/server-managed/queue.ts
CHANGED
|
@@ -21,3 +21,19 @@ export function queue<T>(signal: AbortSignal, { drain = false } = {}): Queue<T>
|
|
|
21
21
|
},
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
// Work done `count` at a time, in the order asked, the rest waiting their
|
|
26
|
+
// turn.
|
|
27
|
+
export function inTurns(count: number): <T>(work: () => Promise<T>) => Promise<T> {
|
|
28
|
+
let active = 0
|
|
29
|
+
const waiting: (() => void)[] = []
|
|
30
|
+
return async work => {
|
|
31
|
+
if (active < count) active++
|
|
32
|
+
else await new Promise<void>(resolve => { waiting.push(resolve) })
|
|
33
|
+
try { return await work() } finally {
|
|
34
|
+
const next = waiting.shift()
|
|
35
|
+
if (next) next()
|
|
36
|
+
else active--
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -179,7 +179,10 @@ export async function renameDiffOf(request: RenameDiffRequest, texts: () => Prom
|
|
|
179
179
|
return opened(await answerOf(request, texts, keep))
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
|
|
182
|
+
// Sides `verified` to be the files the request names (renamedPairs) are read
|
|
183
|
+
// only where no answer is kept or on its way; others are read first, as
|
|
184
|
+
// reading them checks they are.
|
|
185
|
+
async function answerOf(request: RenameDiffRequest, texts: () => Promise<[string, string]>, keep: boolean, verified = false): Promise<Buffer> {
|
|
183
186
|
// Sides not kept are formatted again for each diff (pretty-print.ts), so
|
|
184
187
|
// they are read in its turn: the files a Diff view asks for at once wait
|
|
185
188
|
// here rather than being refused by the formats running. Within its minute
|
|
@@ -193,21 +196,27 @@ async function answerOf(request: RenameDiffRequest, texts: () => Promise<[string
|
|
|
193
196
|
})
|
|
194
197
|
return encoded(diff, request, sides)
|
|
195
198
|
}
|
|
196
|
-
const [before, after] = await texts()
|
|
197
199
|
// A file's type is part of how it is read: the same text as a script and as a module differ.
|
|
198
200
|
const key = `${request.base.hash}\0${request.base.extension}\0${request.other.hash}\0${request.other.extension}`
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
const held = () => {
|
|
202
|
+
const found = kept.get(key)
|
|
203
|
+
if (found) {
|
|
204
|
+
kept.delete(key)
|
|
205
|
+
kept.set(key, found)
|
|
206
|
+
}
|
|
207
|
+
return found ?? pending.get(key)
|
|
204
208
|
}
|
|
205
|
-
let
|
|
206
|
-
if (!
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
+
let answer = verified ? held() : undefined
|
|
210
|
+
if (!answer) {
|
|
211
|
+
const [before, after] = await texts()
|
|
212
|
+
answer = held()
|
|
213
|
+
if (!answer) {
|
|
214
|
+
const deadline = Date.now() + DIFF_TIMEOUT_MS
|
|
215
|
+
const job = inTurn(deadline, () => inWorker(before, after, request, deadline)).then(diff => encoded(diff, request, [before, after])).finally(() => pending.delete(key))
|
|
216
|
+
pending.set(key, answer = job)
|
|
217
|
+
}
|
|
209
218
|
}
|
|
210
|
-
const body = await
|
|
219
|
+
const body = await answer
|
|
211
220
|
kept.set(key, body)
|
|
212
221
|
if (kept.size > KEPT_DIFFS) kept.delete(kept.keys().next().value!)
|
|
213
222
|
return body
|
|
@@ -258,7 +267,7 @@ export function renamedPairs([base, other]: readonly [ComparedSide, ComparedSide
|
|
|
258
267
|
const named = { path: from.path, hash: from.hash, otherPath: to.path, otherHash: to.hash }
|
|
259
268
|
const copy = async (side: ComparedSide, { text, ...file }: typeof from) => prettyText(await side.kept(file) ?? await side.made(file, text))
|
|
260
269
|
let body
|
|
261
|
-
try { body = await answerOf(request, async () => [await copy(base, from), await copy(other, to)], keep) }
|
|
270
|
+
try { body = await answerOf(request, async () => [await copy(base, from), await copy(other, to)], keep, true) }
|
|
262
271
|
catch (err) {
|
|
263
272
|
if (!(err instanceof PrettyError)) throw err
|
|
264
273
|
yield { ...named, error: err.message, status: err.status }
|
|
@@ -146,7 +146,7 @@ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, r
|
|
|
146
146
|
}
|
|
147
147
|
return {
|
|
148
148
|
async open(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, sourcePaths?: Set<string>) {
|
|
149
|
-
const repo = { github:
|
|
149
|
+
const repo = { github: report.repoId == null ? null : await db.getRepoFullName(report.repoId) }
|
|
150
150
|
const target = sourceCacheFilename(report, bundle, permissions, repo, sourcePaths)
|
|
151
151
|
try { return { ...await storage.open(target), repo } }
|
|
152
152
|
catch (error) { if (!(error instanceof CacheMissError)) throw error }
|
|
@@ -15,33 +15,66 @@ async function readBytes(stored: OpenedBlob): Promise<Buffer> {
|
|
|
15
15
|
} finally { stored.stream.destroy() }
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// Ids whose original copy is known gone, at most this many.
|
|
19
|
+
const COMPRESSED_ONLY_MAX = 16_384
|
|
20
|
+
|
|
18
21
|
export function createReportStore(legacy: BlobStore, compressed: BlobStore,
|
|
19
22
|
replaceLegacy: (id: string, bytes: Buffer) => Promise<unknown>): BlobStore {
|
|
23
|
+
// Reports this store wrote, converted, or found with only the compressed
|
|
24
|
+
// copy. Uploads write only that copy and nothing writes an original again,
|
|
25
|
+
// so their reads stop asking storage for one, each a miss (and, encrypted,
|
|
26
|
+
// a row read too). A compressed copy missing there asks for the original
|
|
27
|
+
// after all. An original left by a conversion that failed is never in it:
|
|
28
|
+
// the next read finds that original and resumes the conversion.
|
|
29
|
+
const compressedOnly = new Set<string>()
|
|
30
|
+
function remember(id: string): void {
|
|
31
|
+
compressedOnly.delete(id)
|
|
32
|
+
if (compressedOnly.size >= COMPRESSED_ONLY_MAX) compressedOnly.delete(compressedOnly.values().next().value!)
|
|
33
|
+
compressedOnly.add(id)
|
|
34
|
+
}
|
|
35
|
+
async function openCompressed(id: string): Promise<OpenedBlob | null> {
|
|
36
|
+
const stored = await compressed.open(id)
|
|
37
|
+
if (!stored) return null
|
|
38
|
+
const stream = createBrotliDecompress()
|
|
39
|
+
// pipeline carries storage/authentication errors into the decoded stream
|
|
40
|
+
// and closes the stored source when a reader leaves before EOF.
|
|
41
|
+
void pipeline(stored.stream, stream).catch(() => {})
|
|
42
|
+
return { size: null, stream }
|
|
43
|
+
}
|
|
20
44
|
async function open(id: string): Promise<OpenedBlob | null> {
|
|
45
|
+
if (compressedOnly.has(id)) {
|
|
46
|
+
const stored = await openCompressed(id)
|
|
47
|
+
if (stored) return stored
|
|
48
|
+
compressedOnly.delete(id)
|
|
49
|
+
}
|
|
21
50
|
// Keep the original available until conversion succeeds. Concurrent reads
|
|
22
51
|
// can finish using an already-open stream while another reader converts it.
|
|
23
52
|
const original = await legacy.open(id)
|
|
24
53
|
if (original) {
|
|
25
54
|
const bytes = await readBytes(original)
|
|
26
55
|
await replaceLegacy(id, await encodeBrotli(bytes, 9))
|
|
56
|
+
remember(id)
|
|
27
57
|
return { size: bytes.length, stream: Readable.from([bytes]) }
|
|
28
58
|
}
|
|
29
|
-
const stored = await
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
// pipeline carries storage/authentication errors into the decoded stream
|
|
33
|
-
// and closes the stored source when a reader leaves before EOF.
|
|
34
|
-
void pipeline(stored.stream, stream).catch(() => {})
|
|
35
|
-
return { size: null, stream }
|
|
59
|
+
const stored = await openCompressed(id)
|
|
60
|
+
if (stored) remember(id)
|
|
61
|
+
return stored
|
|
36
62
|
}
|
|
37
63
|
return {
|
|
38
|
-
|
|
64
|
+
async put(id, bytes) {
|
|
65
|
+
const wrapped = await compressed.put(id, await encodeBrotli(bytes, 9))
|
|
66
|
+
remember(id)
|
|
67
|
+
return wrapped
|
|
68
|
+
},
|
|
39
69
|
open,
|
|
40
70
|
async get(id) {
|
|
41
71
|
const stored = await open(id)
|
|
42
72
|
if (!stored) return null
|
|
43
73
|
return readBytes(stored)
|
|
44
74
|
},
|
|
45
|
-
async delete(id) {
|
|
75
|
+
async delete(id) {
|
|
76
|
+
compressedOnly.delete(id)
|
|
77
|
+
await Promise.all([compressed.delete(id), legacy.delete(id)])
|
|
78
|
+
},
|
|
46
79
|
}
|
|
47
80
|
}
|
|
@@ -92,7 +92,8 @@ export function actionsLines(repo: number, owner: string, read: WorkflowsRead, l
|
|
|
92
92
|
if ('error' in read) return { lines: [{ type: 'workflows', repo, ...read } as WorkflowsLine], refs: [] as ActionRef[] }
|
|
93
93
|
const lines: (WorkflowsLine | WorkflowLine)[] = [{ type: 'workflows', repo, paths: read.files.filter(file => file.kind === 'workflow').map(file => file.path) }]
|
|
94
94
|
const refs = new Map<string, ActionRef>()
|
|
95
|
-
|
|
95
|
+
// Its own actions by their directories and workflows by their paths, as a step or a job uses one.
|
|
96
|
+
const actions = new Map(read.files.flatMap(file => file.kind !== 'dockerfile' && 'text' in file ? [[file.kind === 'action' ? actionDir(file.path) : file.path, file.text] as const] : []))
|
|
96
97
|
for (const file of read.files) {
|
|
97
98
|
const scan = 'error' in file ? file : file.kind === 'dockerfile' ? { uses: [], findings: dockerfileFindings(file.text) } : scanWorkflow(file.kind, file.text, { owner, lockfiles, actions })
|
|
98
99
|
lines.push({ type: 'workflow', repo, path: file.path, kind: file.kind, ...scan })
|
|
@@ -151,10 +152,12 @@ export interface ActionsResolving {
|
|
|
151
152
|
store?: KeptStore<ActionLine> | undefined
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
// A reference's line: kept
|
|
155
|
-
// repository's; why not, where
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
// A reference's line: kept (`stored`, as read of the store with others),
|
|
156
|
+
// else resolved, and kept where it is a public repository's; why not, where
|
|
157
|
+
// it couldn't be.
|
|
158
|
+
async function resolved(resolving: ActionsResolving, source: ActionsSource, { key, owner }: ActionRef,
|
|
159
|
+
stored = resolving.store?.read(key)): Promise<ActionLine | null> {
|
|
160
|
+
const kept = resolving.memory.get(key) ?? await stored?.then(row => row && { at: row.at, line: row.value })
|
|
158
161
|
if (kept && Date.now() - kept.at < RISK_KEPT_MS) { resolving.memory.set(key, kept); return kept.line }
|
|
159
162
|
const uses = usesOfKey(key)
|
|
160
163
|
if (!uses) return { type: 'action', key, error: 'invalid-uses' }
|
|
@@ -180,6 +183,9 @@ export function resolvingActions(resolving: ActionsResolving) {
|
|
|
180
183
|
const source = keptSource(sharedSource(resolving.source, resolving.answers))
|
|
181
184
|
const pending: Queue<ActionRef> = queue(resolving.signal)
|
|
182
185
|
const latest = new Map<string, ActionRef>()
|
|
186
|
+
// Those of the references added at once kept beyond this instance, read of
|
|
187
|
+
// the store together as they are added.
|
|
188
|
+
const stored = new Map<string, Promise<{ at: number; value: ActionLine } | null>>()
|
|
183
189
|
let closed = false, waiting = 0
|
|
184
190
|
const settle = () => { if (closed && waiting === 0) pending.close() }
|
|
185
191
|
const add = (refs: ActionRef[]) => {
|
|
@@ -191,11 +197,14 @@ export function resolvingActions(resolving: ActionsResolving) {
|
|
|
191
197
|
waiting++
|
|
192
198
|
pending.add(kept)
|
|
193
199
|
}
|
|
200
|
+
const unread = [...new Set(refs.map(ref => ref.key))].filter(key => latest.has(key) && !stored.has(key) && !resolving.memory.get(key))
|
|
201
|
+
const read = unread.length > 0 ? resolving.store?.readMany(unread) : undefined
|
|
202
|
+
for (const key of read ? unread : []) stored.set(key, read!.then(found => found.get(key) ?? null))
|
|
194
203
|
}
|
|
195
204
|
const work = async () => {
|
|
196
205
|
for (let ref = await pending.take(); ref !== undefined; ref = await pending.take()) {
|
|
197
206
|
try {
|
|
198
|
-
const line = await resolved(resolving, source, ref)
|
|
207
|
+
const line = await resolved(resolving, source, ref, stored.get(ref.key))
|
|
199
208
|
if (line && latest.get(ref.key) === ref) {
|
|
200
209
|
resolving.tell(line)
|
|
201
210
|
const owner = 'repository' in line ? (line.repository?.fullName ?? line.repo).split('/')[0]! : ref.owner
|