@preventive/triage 1.0.0-alpha.47 → 1.0.0-alpha.49
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/dependency-paths.d.ts +2 -1
- package/client/dependency-paths.js +16 -4
- package/common/managed/agent-tasks.ts +12 -14
- package/common/managed/github-agents.ts +7 -6
- package/out/activity.js +4 -4
- package/out/{chunk-NOZJULWS.js → chunk-6LNQTKAV.js} +1 -1
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/{chunk-5AHIQFFG.js → chunk-C2AVODGZ.js} +1 -1
- package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
- package/out/chunk-JEAUAJVF.js +232 -0
- package/out/chunk-O6M574JI.js +1 -0
- package/out/{chunk-VL43BTSO.js → chunk-PBSOL2SL.js} +1 -1
- package/out/{chunk-ZCS4UYHI.js → chunk-PBZPYIG7.js} +1 -1
- package/out/{chunk-6WF3KNTL.js → chunk-SZD5CW5R.js} +2 -2
- package/out/{chunk-SPEFPML6.js → chunk-TQJDYHVE.js} +1 -1
- package/out/{chunk-UENXT2HK.js → chunk-ULIF7A7N.js} +1 -1
- package/out/{chunk-FH24VHSJ.js → chunk-WYLQWYLA.js} +1 -1
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +26 -26
- package/out/compare.js +13 -13
- package/out/dialogs.js +17 -17
- package/out/graph.js +1 -1
- package/out/index.html +7 -7
- package/out/scan.js +1 -1
- package/out/terminal.js +214 -214
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +8 -5
- package/risk/npm/score.js +1 -1
- package/server-managed/agent-merges.ts +38 -22
- package/server-managed/app-agents.ts +6 -8
- package/server-managed/cache-storage.ts +3 -2
- package/server-managed/commit-coauthors.ts +7 -2
- package/server-managed/commit-lockfiles.ts +22 -21
- package/server-managed/db-methods.ts +52 -38
- package/server-managed/db-neon.ts +21 -5
- package/server-managed/db.ts +2 -0
- package/server-managed/dependabot-access.ts +1 -1
- package/server-managed/dependency-review-comments.ts +7 -4
- package/server-managed/dependency-reviews.ts +71 -33
- package/server-managed/github-accounts.ts +28 -1
- package/server-managed/github-app.ts +27 -23
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +8 -1
- package/server-managed/github-metadata.ts +2 -2
- package/server-managed/http-response.ts +28 -10
- package/server-managed/http.ts +751 -273
- package/server-managed/index.ts +2 -2
- package/server-managed/managed-issues.ts +3 -0
- package/server-managed/npm-delta.ts +16 -0
- package/server-managed/npm-kept.ts +32 -5
- package/server-managed/npm-loads.ts +21 -13
- package/server-managed/npm-manifest.ts +1 -1
- package/server-managed/npm-packages.ts +13 -4
- package/server-managed/object-storage.ts +4 -3
- package/server-managed/public-workspace.ts +3 -3
- package/server-managed/pull-comment-reviews.ts +1 -1
- package/server-managed/pull-dependencies.ts +84 -36
- package/server-managed/pull-dependency-reviews.ts +28 -18
- package/server-managed/rename-diff-worker.js +1 -1
- package/server-managed/repository-agents.ts +9 -6
- package/server-managed/repository-browser.ts +13 -3
- package/server-managed/repository-code.ts +153 -7
- package/server-managed/repository-dependency-reviews.ts +12 -4
- package/server-managed/repository-history.ts +85 -152
- package/server-managed/repository-issues.ts +27 -13
- package/server-managed/repository-pulse.ts +31 -11
- package/server-managed/repository-squashes.ts +145 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +3 -2
- package/server-managed/session.ts +34 -12
- package/server-managed/sql.ts +16 -8
- package/server-managed/storage-encryption.ts +16 -8
- package/server-managed/storage-maintenance.ts +5 -2
- package/server-managed/storage-payload.ts +18 -0
- package/server-managed/storage-stores.ts +3 -2
- package/server-managed/team-feed.ts +16 -11
- package/server-managed/team-npm-scopes.ts +29 -0
- package/server-managed/team-reports.ts +68 -16
- package/out/chunk-6ANSHRFQ.js +0 -6
- package/out/chunk-S6U2J62U.js +0 -232
- package/out/chunk-ZGNMDGSB.js +0 -1
package/server-managed/index.ts
CHANGED
|
@@ -44,7 +44,7 @@ export async function createManagedApp(config: ManagedConfig, options: ManagedAp
|
|
|
44
44
|
|
|
45
45
|
async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options }: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
|
|
46
46
|
const storage = await openManagedStorage(config)
|
|
47
|
-
const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage } = storage
|
|
47
|
+
const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage } = storage
|
|
48
48
|
rollback.defer(() => db.close())
|
|
49
49
|
const originGate = createOriginGate(config.host, config.trustProxyEnv)
|
|
50
50
|
|
|
@@ -60,7 +60,7 @@ async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options
|
|
|
60
60
|
indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
|
|
61
61
|
}) : () => false
|
|
62
62
|
const routeRequest = createManagedRequestHandler({
|
|
63
|
-
...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, originGate, serveStatic,
|
|
63
|
+
...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage, originGate, serveStatic,
|
|
64
64
|
...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}), ...(waitUntil ? { waitUntil } : {}),
|
|
65
65
|
isShuttingDown, track,
|
|
66
66
|
})
|
|
@@ -53,6 +53,9 @@ export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
|
|
|
53
53
|
issue_url AS issueUrl, auto_fix_url AS autoFixUrl, auto_fix_checked_at AS autoFixCheckedAt, created_by AS createdBy, created_at AS createdAt`
|
|
54
54
|
const get = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id = ?`)
|
|
55
55
|
const list = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY finding_id`)
|
|
56
|
+
// A lookup's derived Fix replaces the one it read, unless one started later
|
|
57
|
+
// changed it since: the check time, which only changes stamp
|
|
58
|
+
// (github-issue-links.ts issueUpdates), orders them.
|
|
56
59
|
const autoFix = db.prepare(`UPDATE managed_finding_issue SET auto_fix_url = ?, auto_fix_checked_at = ?
|
|
57
60
|
WHERE finding_id = ? AND issue_url = ? AND COALESCE(auto_fix_url, '') = COALESCE(?, '')
|
|
58
61
|
AND (auto_fix_checked_at IS NULL OR auto_fix_checked_at <= ?)`)
|
|
@@ -154,6 +154,22 @@ export function npmDelta(base: Side | null, other: Side, kept: KeptStore<NpmDelt
|
|
|
154
154
|
return work
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// The deltas of those of `sides` (each the integrities of the version it is
|
|
158
|
+
// against, or none, and of the version) held or kept, by their keys
|
|
159
|
+
// (npmDelta's), in one read of `kept` for those not held: none worked out.
|
|
160
|
+
export async function keptNpmDeltas(sides: [string | null, string][], kept: KeptStore<NpmDelta>): Promise<Map<string, NpmDelta>> {
|
|
161
|
+
const keys = [...new Set(sides.map(([base, other]) => `${base ?? '(empty)'}\0${other}`))]
|
|
162
|
+
const found = new Map(keys.flatMap(key => { const known = deltas.get(key); return known ? [[key, known] as const] : [] }))
|
|
163
|
+
const unheld = keys.filter(key => !found.has(key))
|
|
164
|
+
for (const [key, row] of unheld.length > 0 ? await kept.readMany(unheld) : []) {
|
|
165
|
+
const known = deltaOf(row.value)
|
|
166
|
+
if (!known) continue
|
|
167
|
+
deltas.set(key, known)
|
|
168
|
+
found.set(key, known)
|
|
169
|
+
}
|
|
170
|
+
return found
|
|
171
|
+
}
|
|
172
|
+
|
|
157
173
|
// As kept, else its turn awaited, then both sides' files read, counted and kept.
|
|
158
174
|
async function worked(key: string, base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null): Promise<NpmDelta> {
|
|
159
175
|
const known = deltaOf((await kept?.read(key))?.value)
|
|
@@ -11,10 +11,14 @@
|
|
|
11
11
|
// of a repository's GitHub, its issues, pull requests and agents' work, for a
|
|
12
12
|
// day (http.ts keptAnswer), and its history, a page from a commit at a time,
|
|
13
13
|
// for a month (repository-history.ts historyFrom), as are the files each of
|
|
14
|
-
// its commits changes and holds (repository-changes.ts),
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// its commits changes and holds (repository-changes.ts), its trees as the
|
|
15
|
+
// code viewer reads them (codeTreeStore), the packages a pull request's
|
|
16
|
+
// lockfiles add (pull-dependencies.ts), and the lockfiles a commit lists and
|
|
17
|
+
// each one's packages, as its dependency review reads them
|
|
18
|
+
// (commit-lockfiles.ts); and the commit the code viewer keeps its
|
|
19
|
+
// directories at, for as long as it does (codeHeadStore). Each store removes
|
|
20
|
+
// its own rows kept longer than that as it writes, at most once an hour a
|
|
21
|
+
// database (pruneKept), so that rows no read will take again don't pile up.
|
|
18
22
|
// The database is an optimization: a failure reads as a miss, and writes
|
|
19
23
|
// nothing.
|
|
20
24
|
import { createHash } from 'node:crypto'
|
|
@@ -155,7 +159,7 @@ export const repositoryReadStore = (db: UpstreamCacheStore, debug = false): Kept
|
|
|
155
159
|
export const HISTORY_PAGE_KEPT_MS = 30 * 24 * 60 * 60_000
|
|
156
160
|
export const historyPageStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/history', HISTORY_PAGE_KEPT_MS, debug)
|
|
157
161
|
// The pull requests squashed into a repository's commits, by the commit
|
|
158
|
-
// (repository-
|
|
162
|
+
// (repository-squashes.ts squashedPulls): kept as its history's pages are.
|
|
159
163
|
export const historySquashStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/squashes', HISTORY_PAGE_KEPT_MS, debug)
|
|
160
164
|
// The files each of a repository's commits changes (repository-changes.ts
|
|
161
165
|
// repositoryChanges), by the commit, which never change: kept as its
|
|
@@ -165,6 +169,21 @@ export const historyChangeStore = <T>(db: UpstreamCacheStore, debug = false): Ke
|
|
|
165
169
|
// repositoryTree), by the commit, which never change: kept as its history's
|
|
166
170
|
// pages are.
|
|
167
171
|
export const historyTreeStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/trees', HISTORY_PAGE_KEPT_MS, debug)
|
|
172
|
+
// What the code viewer read of a repository's trees (http.ts
|
|
173
|
+
// repositoryTreesOf), which never changes once a commit or tree names it: the
|
|
174
|
+
// tree at a directory of a commit, by the repository's id, the commit and the
|
|
175
|
+
// directory (`trees`), and how many files a tree holds and their bytes, by
|
|
176
|
+
// the repository's id and the tree's (`tree-files`): kept as its history's
|
|
177
|
+
// pages are.
|
|
178
|
+
export const codeTreeStore = <T>(db: UpstreamCacheStore, type: 'trees' | 'tree-files', debug = false): KeptStore<T> =>
|
|
179
|
+
keptStore<T>(db, `repository/code-${type}`, HISTORY_PAGE_KEPT_MS, debug)
|
|
180
|
+
// The commit the code viewer keeps a repository's directories at in byte
|
|
181
|
+
// storage, its default branch's head as last read, and the repository's
|
|
182
|
+
// name they are kept under (repository-code.ts repositoryCodeKept), by the
|
|
183
|
+
// repository's id: kept until the head moves, the repository is renamed or
|
|
184
|
+
// it is removed, one for each repository.
|
|
185
|
+
export const codeHeadStore = (db: UpstreamCacheStore, debug = false): KeptStore<{ commit: string; repo: string }> =>
|
|
186
|
+
keptStore<{ commit: string; repo: string }>(db, 'repository/code-heads', Infinity, debug)
|
|
168
187
|
// The packages a repository's pull request's lockfiles add
|
|
169
188
|
// (pull-dependencies.ts), by the commits of its base and head, which never
|
|
170
189
|
// change: kept as its history's pages are.
|
|
@@ -178,3 +197,11 @@ export const pullDependencyStore = <T>(db: UpstreamCacheStore, debug = false): K
|
|
|
178
197
|
export const npmDeltaStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'npm/deltas', HISTORY_PAGE_KEPT_MS, debug)
|
|
179
198
|
export const reviewBaselineStore = (db: UpstreamCacheStore, debug = false): KeptStore<{ versions: string[]; unread: string[] }> =>
|
|
180
199
|
keptStore<{ versions: string[]; unread: string[] }>(db, 'repository/review-baselines', HISTORY_PAGE_KEPT_MS, debug)
|
|
200
|
+
// What a repository's dependency review read of GitHub (pull-dependencies.ts
|
|
201
|
+
// LockfileStore): the lockfiles a commit lists (`listings`), by the commit,
|
|
202
|
+
// and a lockfile's packages with what their reviews are held to
|
|
203
|
+
// (`lockfiles`), by its blob, which never change: kept as its history's
|
|
204
|
+
// pages are, apart from a scan's (scanReadStore), which reads them without
|
|
205
|
+
// what their reviews are held to.
|
|
206
|
+
export const lockfileReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'lockfiles', debug = false): KeptStore<T> =>
|
|
207
|
+
keptStore<T>(db, `repository/review-${type}`, HISTORY_PAGE_KEPT_MS, debug)
|
|
@@ -50,9 +50,9 @@ const refused = <T>(status: number, code: string): NpmLoad<T> => {
|
|
|
50
50
|
return { result, release() {} }
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// What names a version's tarball, and so its files: its integrity
|
|
54
|
-
// and whether it is read with the token.
|
|
55
|
-
const tarballKey = (doc: NpmVersionDocument) => `${doc.dist.integrity} ${doc.dist.tarball} ${doc.private}`
|
|
53
|
+
// What names a version's tarball, and so its files: its integrity, its
|
|
54
|
+
// shasum and URL, and whether it is read with the token.
|
|
55
|
+
const tarballKey = (doc: NpmVersionDocument) => `${doc.dist.integrity} ${doc.dist.shasum ?? ''} ${doc.dist.tarball} ${doc.private}`
|
|
56
56
|
|
|
57
57
|
function shared<T>(what: string, doc: NpmVersionDocument, work: () => Promise<T>): NpmLoad<T> {
|
|
58
58
|
const { unpackedSize, fileCount } = doc.dist
|
|
@@ -118,7 +118,13 @@ function done(key: string, load: Load) {
|
|
|
118
118
|
// The sha512 an integrity names, in base64, or null where it names none.
|
|
119
119
|
const sha512Of = (integrity: string) => /(?:^|\s)sha512-([\d+/A-Za-z]{86}==)(?=\s|$)/u.exec(integrity)?.[1] ?? null
|
|
120
120
|
// Hashed off the event loop: a tarball runs to tens of MiB.
|
|
121
|
-
const
|
|
121
|
+
const digest = async (algorithm: string, bytes: Uint8Array) => Buffer.from(await crypto.subtle.digest(algorithm, bytes as Uint8Array<ArrayBuffer>))
|
|
122
|
+
// Whether `bytes` are those of the sha512 an integrity names and, where there
|
|
123
|
+
// is one, of the shasum (a sha1, in hex).
|
|
124
|
+
async function hashedAs(bytes: Uint8Array, sha512: string, shasum: string | null): Promise<boolean> {
|
|
125
|
+
const [own512, own1] = await Promise.all([digest('SHA-512', bytes), shasum === null ? null : digest('SHA-1', bytes)])
|
|
126
|
+
return own512.toString('base64') === sha512 && (own1 === null || own1.toString('hex') === shasum)
|
|
127
|
+
}
|
|
122
128
|
|
|
123
129
|
// Upstream's disk cache (setCacheDir), where tarballs are kept between
|
|
124
130
|
// loads, or null to keep none and read none.
|
|
@@ -151,23 +157,25 @@ async function downloadTarball(url: string, isPrivate: boolean): Promise<Buffer>
|
|
|
151
157
|
return bytes
|
|
152
158
|
}
|
|
153
159
|
|
|
154
|
-
// The version's tarball, its bytes held to the document's sha512
|
|
155
|
-
// they come from: kept in the cache,
|
|
156
|
-
// fetch with the token, or in npm's; else
|
|
157
|
-
// matching the
|
|
158
|
-
// reader may download, wherever they
|
|
160
|
+
// The version's tarball, its bytes held to the document's sha512, and to its
|
|
161
|
+
// shasum where it tells one, wherever they come from: kept in the cache,
|
|
162
|
+
// where bundle builds keep what they fetch with the token, or in npm's; else
|
|
163
|
+
// downloaded, and then kept. Bytes matching the hashes the reader's own
|
|
164
|
+
// document names are the tarball that reader may download, wherever they
|
|
165
|
+
// were kept and whoever fetched them.
|
|
159
166
|
async function readTarball(doc: NpmVersionDocument): Promise<Uint8Array> {
|
|
160
|
-
const expected = sha512Of(doc.dist.integrity), url = doc.dist.tarball
|
|
161
|
-
if (!url.startsWith(`${NPM_REGISTRY}/${doc.name}/-/`) || URL.parse(url)?.href !== url || expected === null
|
|
167
|
+
const expected = sha512Of(doc.dist.integrity), shasum = doc.dist.shasum ?? null, url = doc.dist.tarball
|
|
168
|
+
if (!url.startsWith(`${NPM_REGISTRY}/${doc.name}/-/`) || URL.parse(url)?.href !== url || expected === null
|
|
169
|
+
|| (shasum !== null && !/^[\da-f]{40}$/u.test(shasum))) throw new NpmPackageError(502, 'upstream-invalid')
|
|
162
170
|
const kept = tarballCache === null ? null : upstreamCachePath(tarballCache, 'npm/tarballs', `${doc.name}@${doc.version}.tgz`)
|
|
163
171
|
if (kept !== null) {
|
|
164
172
|
for (const path of [kept, npmCachePath(expected)]) {
|
|
165
173
|
const bytes = path === null ? null : await readKeptFile(path, MAX_TAR_BYTES)
|
|
166
|
-
if (bytes && await
|
|
174
|
+
if (bytes && await hashedAs(bytes, expected, shasum)) return bytes
|
|
167
175
|
}
|
|
168
176
|
}
|
|
169
177
|
const bytes = await downloadTarball(url, doc.private)
|
|
170
|
-
if (await
|
|
178
|
+
if (!await hashedAs(bytes, expected, shasum)) throw new NpmPackageError(502, 'upstream-invalid')
|
|
171
179
|
if (kept !== null) await keepFile(kept, bytes)
|
|
172
180
|
return bytes
|
|
173
181
|
}
|
|
@@ -102,7 +102,7 @@ export function npmManifest(json: Record<string, unknown>): Record<string, unkno
|
|
|
102
102
|
// as they read npm's.
|
|
103
103
|
const VERSION_FIELDS = ['name', 'version', 'description', 'license', 'homepage', 'main', 'module', 'types', 'type', 'deprecated', 'gitHead', 'author', '_npmUser',
|
|
104
104
|
'maintainers', 'keywords', 'bin', 'dependencies', 'peerDependencies', 'optionalDependencies', 'engines', 'hasInstallScript', 'repository', 'bugs']
|
|
105
|
-
const DIST_FIELDS = ['tarball', 'integrity', 'unpackedSize', 'fileCount']
|
|
105
|
+
const DIST_FIELDS = ['tarball', 'integrity', 'shasum', 'unpackedSize', 'fileCount']
|
|
106
106
|
const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall']
|
|
107
107
|
const picked = (json: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(key => Object.hasOwn(json, key)).map(key => [key, json[key]]))
|
|
108
108
|
|
|
@@ -228,7 +228,9 @@ export interface NpmVersionDocument {
|
|
|
228
228
|
name: string
|
|
229
229
|
version: string
|
|
230
230
|
private: boolean
|
|
231
|
-
|
|
231
|
+
// `shasum`, the tarball's sha1 in hex, where npm tells it: held, with the
|
|
232
|
+
// integrity, to the tarball its files are read from (npm-loads.ts).
|
|
233
|
+
dist: { tarball: string; integrity: string; shasum: string | null; unpackedSize: number | null; fileCount: number | null }
|
|
232
234
|
manifest: Record<string, unknown>
|
|
233
235
|
}
|
|
234
236
|
|
|
@@ -238,10 +240,12 @@ const count = (value: unknown) => Number.isSafeInteger(value) && (value as numbe
|
|
|
238
240
|
function versionDocument(name: string, json: Record<string, unknown>, isPrivate: boolean): NpmVersionDocument {
|
|
239
241
|
const dist = json['dist']
|
|
240
242
|
if (json['name'] !== name || typeof json['version'] !== 'string' || !isNpmPackageSpec(json['version']) || !plainObject(dist)
|
|
241
|
-
|| typeof dist['tarball'] !== 'string' || typeof dist['integrity'] !== 'string'
|
|
243
|
+
|| typeof dist['tarball'] !== 'string' || typeof dist['integrity'] !== 'string'
|
|
244
|
+
|| (dist['shasum'] !== undefined && typeof dist['shasum'] !== 'string')) throw new NpmPackageError(502, 'upstream-invalid')
|
|
242
245
|
return {
|
|
243
246
|
name, version: json['version'], private: isPrivate,
|
|
244
|
-
dist: { tarball: dist['tarball'], integrity: dist['integrity'],
|
|
247
|
+
dist: { tarball: dist['tarball'], integrity: dist['integrity'], shasum: typeof dist['shasum'] === 'string' ? dist['shasum'] : null,
|
|
248
|
+
unpackedSize: count(dist['unpackedSize']), fileCount: count(dist['fileCount']) },
|
|
245
249
|
manifest: npmManifest(json),
|
|
246
250
|
}
|
|
247
251
|
}
|
|
@@ -262,13 +266,18 @@ export interface NpmVersionStore {
|
|
|
262
266
|
// changes a version's files, but its document can change: a deprecation, the
|
|
263
267
|
// package made private (a scoped one can be), the version removed (which whoever
|
|
264
268
|
// reads it from a store learns elsewhere too: risk does). A day bounds each.
|
|
269
|
+
// One kept with no shasum, as none was before it was kept (npmVersionFields),
|
|
270
|
+
// is asked of npm again, which tells one of each version.
|
|
265
271
|
export const VERSION_KEPT_MS = 24 * 60 * 60_000
|
|
266
272
|
export function readNpmVersion(name: string, spec: string, privileged: boolean, signal: AbortSignal, store?: NpmVersionStore): Promise<NpmVersionDocument | null> {
|
|
267
273
|
if (!isNpmPackageName(name) || !isNpmPackageSpec(spec)) return Promise.reject(new NpmPackageError(400, 'bad-package'))
|
|
268
274
|
// A kept one is read within the same budget as npm's answer.
|
|
269
275
|
return withDocumentBudget(VERSION_DOCUMENT_BYTES, async () => {
|
|
270
276
|
const kept = store && isExactVersion(spec) ? await store.read(name, spec) : null
|
|
271
|
-
|
|
277
|
+
const keptDist = kept?.json['dist']
|
|
278
|
+
if (kept && Date.now() - kept.at < VERSION_KEPT_MS && plainObject(keptDist) && typeof keptDist['shasum'] === 'string') {
|
|
279
|
+
try { return versionDocument(name, kept.json, false) } catch {}
|
|
280
|
+
}
|
|
272
281
|
const found = await readDocument(name, registryUrl(name, spec), privileged, { accept: 'application/json', limit: VERSION_DOCUMENT_BYTES, signal })
|
|
273
282
|
if (!found) return null
|
|
274
283
|
const doc = versionDocument(name, found.json, found.private)
|
|
@@ -30,7 +30,8 @@ export interface ObjectStorage {
|
|
|
30
30
|
exists(key: string): Promise<boolean>
|
|
31
31
|
put(key: string, bytes: Buffer, options?: { replaceReport?: boolean }): Promise<string | null>
|
|
32
32
|
delete(key: string): Promise<void>
|
|
33
|
-
|
|
33
|
+
// Every object under `prefix`, but those under `keep`, a prefix within it.
|
|
34
|
+
deletePrefix(prefix: string, keep?: string): Promise<void>
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export function isBlobId(value: string): boolean {
|
|
@@ -52,14 +53,14 @@ export async function openObjectVersion(raw: RawObjectStorage, key: string, vers
|
|
|
52
53
|
return null
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
export async function deleteObjects(raw: RawObjectStorage, prefix: string, signal: AbortSignal, before?: number): Promise<number> {
|
|
56
|
+
export async function deleteObjects(raw: RawObjectStorage, prefix: string, signal: AbortSignal, before?: number, keep?: string): Promise<number> {
|
|
56
57
|
const eligible = (modifiedAt: number) => before === undefined || modifiedAt < before
|
|
57
58
|
// Finish listing before deleting: provider cursors may be offset-based.
|
|
58
59
|
const cursors = new Set<string>(), keys = new Set<string>()
|
|
59
60
|
let cursor: string | null = null
|
|
60
61
|
do {
|
|
61
62
|
const page = await raw.list(prefix, cursor, 100, signal)
|
|
62
|
-
for (const object of page.objects) if (eligible(object.modifiedAt)) keys.add(object.key)
|
|
63
|
+
for (const object of page.objects) if (eligible(object.modifiedAt) && (keep === undefined || !object.key.startsWith(keep))) keys.add(object.key)
|
|
63
64
|
cursor = page.cursor
|
|
64
65
|
if (cursor !== null && cursors.has(cursor)) throw new Error('Invalid blob pagination')
|
|
65
66
|
if (cursor !== null) cursors.add(cursor)
|
|
@@ -7,7 +7,7 @@ import type { OpenedBlob } from './blob-store.ts'
|
|
|
7
7
|
import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
|
|
8
8
|
import { sendJson, sendLines, writeResponse } from './http-response.ts'
|
|
9
9
|
import { hashToken } from './crypto.ts'
|
|
10
|
-
import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, teamReportVisibility } from './team-reports.ts'
|
|
10
|
+
import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, loadTeamTreesResponse, teamReportVisibility } from './team-reports.ts'
|
|
11
11
|
import { triageWireEntry } from './triage-response.ts'
|
|
12
12
|
import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
13
13
|
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
@@ -34,7 +34,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
34
34
|
const method = req.method ?? 'GET'
|
|
35
35
|
if (method !== 'GET' && method !== 'HEAD') { json(res, 403, { error: 'share-read-only' }); return }
|
|
36
36
|
const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
|
|
37
|
-
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
|
|
37
|
+
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|trees|feed|annotations)$/u.exec(url.pathname)
|
|
38
38
|
const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
|
|
39
39
|
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only|comparison)$/u.exec(url.pathname)
|
|
40
40
|
if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
|
|
@@ -84,7 +84,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
84
84
|
await send(await loadTeamAnnotations(deps.db, deps.reportStore, snapshot, url.searchParams.get('reportId'))); return
|
|
85
85
|
}
|
|
86
86
|
if (teamRoute[2] === 'shared') { await sendTeam(); return }
|
|
87
|
-
const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
|
|
87
|
+
const body = await (teamRoute[2] === 'trees' ? loadTeamTreesResponse : loadTeamReportsResponse)(deps.db, deps.reportStore, snapshot)
|
|
88
88
|
await recheck()
|
|
89
89
|
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
|
|
90
90
|
writeResponse(res, body); return
|
|
@@ -132,7 +132,7 @@ function counted(answer: CommentReview[], read: Authored[], reviewers: Map<strin
|
|
|
132
132
|
const { name, version, integrity, patchHash, status } = dependency ?? {}
|
|
133
133
|
if (typeof name !== 'string' || typeof version !== 'string' || typeof integrity !== 'string' || !VERDICTS.has(status)) return []
|
|
134
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 }))
|
|
135
|
+
basePackage: null, baseVersion: null, reviewer, login: review.user as string, status: status as ReviewVerdict, full: true, time }))
|
|
136
136
|
})
|
|
137
137
|
})
|
|
138
138
|
}
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
// changes, so what is told of one is kept by them (`kept`), and each
|
|
23
23
|
// lockfile read is kept in this instance's memory by its commit
|
|
24
24
|
// (`lockfileReads`): the pull requests branching off the same commit, as a
|
|
25
|
-
// bot's do, read their base's once.
|
|
25
|
+
// bot's do, read their base's once. One read as a commit lists it, with its
|
|
26
|
+
// blob's id (commit-lockfiles.ts), is kept by its blob instead, and beyond
|
|
27
|
+
// this instance where its reader keeps it (LockfileStore): what a blob holds
|
|
28
|
+
// never changes either, and a push changing no lockfile changes none.
|
|
26
29
|
import { createClient } from '@preventive/upstream/github.js'
|
|
27
30
|
import { GithubApiError, githubJson } from './github-app.ts'
|
|
28
31
|
import { upstreamGithub } from './github-errors.ts'
|
|
@@ -46,7 +49,8 @@ export const MAX_LOCKED = 20_000
|
|
|
46
49
|
// Lockfiles read at once.
|
|
47
50
|
const READ_PARALLEL = 4
|
|
48
51
|
// The rules what is told was told by, kept with it (http.ts
|
|
49
|
-
// pullDependencyKept)
|
|
52
|
+
// pullDependencyKept), and the lockfiles read for it (lockfileKept): one
|
|
53
|
+
// told or read by others is told or read again.
|
|
50
54
|
export const PULL_DEPENDENCIES_READ = 3
|
|
51
55
|
const SHA = /^[a-f\d]{40}$/u
|
|
52
56
|
|
|
@@ -104,20 +108,42 @@ export interface PullDependencyStore {
|
|
|
104
108
|
write(key: string, value: PullDependencies): Promise<void>
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
// A lockfile's packages, or why its reader refused it, by its repository
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
type LockfileRead = { packages: LockedPackage[] } | { error: string; detail?: string }
|
|
111
|
+
// A lockfile's packages, or why its reader refused it, by its repository and
|
|
112
|
+
// readKey, read in this instance; by the packages each holds, as the scan's
|
|
113
|
+
// are (repository-risk.ts riskCaches).
|
|
114
|
+
export type LockfileRead = { packages: LockedPackage[] } | { error: string; detail?: string }
|
|
111
115
|
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
|
|
113
|
-
|
|
116
|
+
// Those being read, by the token reading them too, lest each asking at once
|
|
117
|
+
// read them, with whether GitHub was asked.
|
|
118
|
+
const lockfilesReading = new Map<string, Promise<{ read: LockfileRead; github: boolean }>>()
|
|
119
|
+
// A lockfile at a commit as findRepoLockfiles lists it, by its path, with its
|
|
120
|
+
// format (lockfileFormat) and its blob's id (`sha`).
|
|
121
|
+
export interface ListedLockfile { path: string; format: Format; sha: string }
|
|
122
|
+
// What a repository's lockfiles read for its dependency review keep beyond
|
|
123
|
+
// this instance (http.ts lockfileKept), by what never changes: a commit's
|
|
124
|
+
// lockfiles as listed (`listings`, by the commit), and a lockfile's read
|
|
125
|
+
// (`lockfiles`, by its readKey). A read GitHub failed is kept nowhere.
|
|
126
|
+
export interface LockfileStore {
|
|
127
|
+
listings: { read(commit: string): Promise<ListedLockfile[] | null>; write(commit: string, listed: ListedLockfile[]): Promise<void> }
|
|
128
|
+
lockfiles: { read(key: string): Promise<LockfileRead | null>; write(key: string, read: LockfileRead): Promise<void> }
|
|
129
|
+
}
|
|
130
|
+
// Set where GitHub was asked for what a read reads (`github`), not all of it
|
|
131
|
+
// held in this instance or kept beyond it.
|
|
132
|
+
export interface GithubAsked { github: boolean }
|
|
133
|
+
|
|
134
|
+
// A lockfile's read, by its blob where it is listed with its id (`sha`),
|
|
135
|
+
// which with its format alone tell how it reads; else by the commit and its
|
|
136
|
+
// path.
|
|
137
|
+
const readKey = (ref: string, { path, format, sha }: { path: string; format: Format; sha?: string }) =>
|
|
138
|
+
sha ? `blob\0${format}\0${sha}` : `commit\0${ref}\0${path}`
|
|
114
139
|
|
|
115
140
|
const api = (fullName: string) => `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
|
|
116
141
|
|
|
117
142
|
// The pull request's base's and head's commits, as GitHub has them now: its
|
|
118
143
|
// base's moving on as it does, its head's with each push, both kept once it
|
|
119
144
|
// is merged or closed.
|
|
120
|
-
|
|
145
|
+
export interface PullCommits { base: string; head: string }
|
|
146
|
+
async function pullCommits(fullName: string, token: string, number: number, fetchImpl: typeof fetch): Promise<PullCommits> {
|
|
121
147
|
const pull = await githubJson(`${api(fullName)}/pulls/${number}`, token, fetchImpl) as
|
|
122
148
|
{ number?: unknown; base?: { sha?: unknown; repo?: { full_name?: unknown } | null }; head?: { sha?: unknown } } | null
|
|
123
149
|
const base = pull?.base?.sha, head = pull?.head?.sha, repo = pull?.base?.repo?.full_name
|
|
@@ -129,13 +155,16 @@ async function pullCommits(fullName: string, token: string, number: number, fetc
|
|
|
129
155
|
// The pull request's merge base and the files it changes from it: as GitHub
|
|
130
156
|
// compares its base with its head, else, where that lists as many as it lists
|
|
131
157
|
// 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
|
-
|
|
158
|
+
// that lists as many as it lists at most, too. That list is of its commits
|
|
159
|
+
// now: null where they are no longer `base` and `head` (`current`).
|
|
160
|
+
async function pullFiles(fullName: string, token: string, number: number, base: string, head: string, fetchImpl: typeof fetch,
|
|
161
|
+
current: () => Promise<boolean>) {
|
|
134
162
|
const compared = await githubJson(`${api(fullName)}/compare/${base}...${head}?per_page=1`, token, fetchImpl) as { merge_base_commit?: { sha?: unknown } } | null
|
|
135
163
|
const mergeBase = compared?.merge_base_commit?.sha
|
|
136
164
|
if (typeof mergeBase !== 'string' || !SHA.test(mergeBase)) throw new GithubApiError(502, 'github-malformed')
|
|
137
165
|
const { files, more } = commitChanges(compared)
|
|
138
166
|
if (!more) return { mergeBase, files, cut: false }
|
|
167
|
+
if (!await current()) return null
|
|
139
168
|
const listed: ChangedFile[] = []
|
|
140
169
|
for (let page = 1; listed.length < PULL_FILES; page++) {
|
|
141
170
|
const data = await githubJson(`${api(fullName)}/pulls/${number}/files?per_page=${FILES_PAGE}&page=${page}`, token, fetchImpl)
|
|
@@ -171,39 +200,49 @@ function lockfilesChanged(files: ChangedFile[]) {
|
|
|
171
200
|
omitted: all.slice(MAX_DIRECTORIES).flatMap(([, directory]) => [...directory.paths]).toSorted() }
|
|
172
201
|
}
|
|
173
202
|
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
const shared = `${token}\0${key}`
|
|
189
|
-
const reading = lockfilesReading.get(shared) ?? readOf(fullName, token, ref, lockfile, key).finally(() => lockfilesReading.delete(shared))
|
|
203
|
+
// A lockfile's packages at `ref`, as read in this instance, else as kept
|
|
204
|
+
// beyond it (`kept`), else shared with a read of it with the same token still
|
|
205
|
+
// under way, else of GitHub, and kept; a lockfile its reader refuses, why,
|
|
206
|
+
// kept as well, its reader refusing it again; `asked` told where GitHub was.
|
|
207
|
+
// Any failure to read it of GitHub but its having none there is the whole
|
|
208
|
+
// answer's, as it would be every file's; neither is kept.
|
|
209
|
+
export async function readLockfile(fullName: string, token: string, ref: string, lockfile: { path: string; format: Format; sha?: string },
|
|
210
|
+
kept: LockfileStore | null = null, asked?: GithubAsked): Promise<LockfileRead> {
|
|
211
|
+
if (lockfile.format === 'bun') return { error: 'lockfile-unsupported' }
|
|
212
|
+
const key = readKey(ref, lockfile)
|
|
213
|
+
const known = lockfileReads.get(`${fullName}\0${key}`)
|
|
214
|
+
if (known) return known
|
|
215
|
+
const shared = `${token}\0${fullName}\0${key}`
|
|
216
|
+
const reading = lockfilesReading.get(shared) ?? readOf(fullName, token, ref, lockfile, key, kept).finally(() => lockfilesReading.delete(shared))
|
|
190
217
|
lockfilesReading.set(shared, reading)
|
|
191
|
-
|
|
218
|
+
// One failing was asked of GitHub too, though a caller swallows its
|
|
219
|
+
// failure, as a baseline's does: its access is asked again all the same.
|
|
220
|
+
let told
|
|
221
|
+
try { told = await reading } catch (err) {
|
|
222
|
+
if (asked) asked.github = true
|
|
223
|
+
throw err
|
|
224
|
+
}
|
|
225
|
+
if (told.github && asked) asked.github = true
|
|
226
|
+
return told.read
|
|
192
227
|
}
|
|
193
|
-
async function readOf(fullName: string, token: string, ref: string, { path, format }: { path: string; format: Format }, key: string
|
|
228
|
+
async function readOf(fullName: string, token: string, ref: string, { path, format }: { path: string; format: Format }, key: string,
|
|
229
|
+
kept: LockfileStore | null): Promise<{ read: LockfileRead; github: boolean }> {
|
|
230
|
+
const stored = await kept?.lockfiles.read(key)
|
|
231
|
+
if (stored) { lockfileReads.set(`${fullName}\0${key}`, stored); return { read: stored, github: false } }
|
|
194
232
|
let text: string
|
|
195
233
|
try {
|
|
196
234
|
text = await upstreamGithub(() => createClient({ token, userAgent: 'deepview-triage' }).getRepoFile({ repo: fullName, path, ref }))
|
|
197
235
|
} catch (err) {
|
|
198
|
-
if (err instanceof GithubApiError && err.message === 'github-not-found') return { error: 'github-not-found' }
|
|
236
|
+
if (err instanceof GithubApiError && err.message === 'github-not-found') return { read: { error: 'github-not-found' }, github: true }
|
|
199
237
|
throw err
|
|
200
238
|
}
|
|
201
239
|
let read: LockfileRead
|
|
202
240
|
try { read = { packages: lockedPackages(format, text, { review: true }) } } catch (error) {
|
|
203
241
|
read = { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
|
|
204
242
|
}
|
|
205
|
-
lockfileReads.set(key
|
|
206
|
-
|
|
243
|
+
lockfileReads.set(`${fullName}\0${key}`, read)
|
|
244
|
+
await kept?.lockfiles.write(key, read)
|
|
245
|
+
return { read, github: true }
|
|
207
246
|
}
|
|
208
247
|
|
|
209
248
|
// Runs `run` for each of `items`, as many at once as `parallel`, each's
|
|
@@ -278,14 +317,23 @@ export async function pullDependencies(fullName: string, token: string, number:
|
|
|
278
317
|
return (await pullDependenciesAt(fullName, token, number, kept, fetchImpl)).told
|
|
279
318
|
}
|
|
280
319
|
// The same, with the commits it is kept by (`commits`), its base's and
|
|
281
|
-
// head's, by which `kept` reads it
|
|
320
|
+
// head's, by which `kept` reads it: those `given`, as a page of its
|
|
321
|
+
// repository's open pull requests read them (repository-issues.ts), else as
|
|
322
|
+
// GitHub has them now. Commits given that its own list of its files is no
|
|
323
|
+
// longer of are read as GitHub has them now.
|
|
282
324
|
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)
|
|
325
|
+
fetchImpl: typeof fetch = globalThis.fetch, given?: PullCommits): Promise<{ commits: string; told: PullDependencies }> {
|
|
326
|
+
const { base, head } = given ?? await pullCommits(fullName, token, number, fetchImpl)
|
|
285
327
|
const key = `${base}...${head}`
|
|
286
328
|
const held = await kept.read(key)
|
|
287
329
|
if (held) return { commits: key, told: held }
|
|
288
|
-
const
|
|
330
|
+
const current = async () => {
|
|
331
|
+
const now = given && await pullCommits(fullName, token, number, fetchImpl)
|
|
332
|
+
return !now || (now.base === base && now.head === head)
|
|
333
|
+
}
|
|
334
|
+
const pulled = await pullFiles(fullName, token, number, base, head, fetchImpl, current)
|
|
335
|
+
if (!pulled) return pullDependenciesAt(fullName, token, number, kept, fetchImpl)
|
|
336
|
+
const { mergeBase, files, cut: filesCut } = pulled
|
|
289
337
|
const { directories, cut, omitted } = lockfilesChanged(files)
|
|
290
338
|
const reads = directories.flatMap(({ before, after }) => [...before.map(lockfile => [mergeBase, lockfile] as const), ...after.map(lockfile => [head, lockfile] as const)])
|
|
291
339
|
const read = await eachOf(reads, READ_PARALLEL, ([ref, lockfile]) => readLockfile(fullName, token, ref, lockfile))
|
|
@@ -24,12 +24,12 @@
|
|
|
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 {
|
|
27
|
+
import { 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'
|
|
31
31
|
import { type Lru, lru } from './lru.ts'
|
|
32
|
-
import { type DirectoryDependencies, MAX_ADDED, type PullDependencies, lockedAs, lockedWith } from './pull-dependencies.ts'
|
|
32
|
+
import { type DirectoryDependencies, type GithubAsked, type LockfileStore, MAX_ADDED, type PullDependencies, lockedAs, lockedWith } from './pull-dependencies.ts'
|
|
33
33
|
import type { PublishedReview, RepoReviewState } from './repository-dependency-reviews.ts'
|
|
34
34
|
import { type ReviewedDependencies, type ReviewedDependency, askReviewed } from './reviewed-dependencies.ts'
|
|
35
35
|
|
|
@@ -64,17 +64,19 @@ const hashesOf = (pkg: LockedPackage) => (pkg.integrity ?? '').split(/\s+/u).fil
|
|
|
64
64
|
const baselineKeys = (pkg: LockedPackage) => hashesOf(pkg).map(hash => `${pkg.name}@${pkg.version ?? ''}\0${hash}\0${pkg.patchHash ?? ''}`)
|
|
65
65
|
|
|
66
66
|
// The versions the repository's lockfiles locked at commit `since` that are
|
|
67
|
-
// reviewed (reviewedAs), as kept, else read of GitHub (commit-lockfiles.ts
|
|
68
|
-
// and kept; with the lockfiles there its
|
|
69
|
-
// past those read (`unread`). GitHub having
|
|
70
|
-
// there is a failure of the whole, as of any
|
|
71
|
-
|
|
67
|
+
// reviewed (reviewedAs), as kept, else read of GitHub (commit-lockfiles.ts,
|
|
68
|
+
// its lockfiles as `reads` keeps them) and kept; with the lockfiles there its
|
|
69
|
+
// reader refuses, isn't one of, or past those read (`unread`). GitHub having
|
|
70
|
+
// none of a lockfile it lists there is a failure of the whole, as of any
|
|
71
|
+
// file, never kept.
|
|
72
|
+
export async function reviewBaseline(fullName: string, token: string, repoId: number, since: string, kept: ReviewBaselineStore, reads: LockfileStore | null = null,
|
|
73
|
+
asked?: GithubAsked): Promise<Baseline> {
|
|
72
74
|
const key = `${repoId}\0${since}`
|
|
73
75
|
const known = baselines.get(key)
|
|
74
76
|
if (known) return known
|
|
75
77
|
const stored = await kept.read(key)
|
|
76
78
|
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, () => true, asked)
|
|
79
|
+
const read = await lockedAt(fullName, token, since, reads, () => true, asked)
|
|
78
80
|
const { packages } = lockedIn(read), { unread } = read
|
|
79
81
|
const versions = new Set<string>()
|
|
80
82
|
for (const pkg of packages) if (reviewedAs(pkg) === 'version') for (const held of baselineKeys(pkg)) versions.add(held)
|
|
@@ -105,7 +107,9 @@ const ACCEPTING = new Set(['accept', 'accept-with-nits'])
|
|
|
105
107
|
// review of theirs, whole or compared with one of `bases` of its package
|
|
106
108
|
// (any, for `any`); and how many review it in its pull request's comments
|
|
107
109
|
// alone (`commented`), which count for it alone: whose reviews passing it,
|
|
108
|
-
// else whose latest, are its comments'.
|
|
110
|
+
// else whose latest, are its comments'. Each reviewer too (`reviewers`), by
|
|
111
|
+
// their login, their latest verdict, whether they pass it, and whether that
|
|
112
|
+
// verdict is its comments' (`comment`), which counts for it alone.
|
|
109
113
|
function stancesOf(pkg: LockedPackage, reviews: ReviewsOf, bases: string[] | 'any') {
|
|
110
114
|
const hashes = hashesOf(pkg)
|
|
111
115
|
const by = new Map<string, PublishedReview[]>()
|
|
@@ -116,17 +120,21 @@ function stancesOf(pkg: LockedPackage, reviews: ReviewsOf, bases: string[] | 'an
|
|
|
116
120
|
const qualifies = (review: PublishedReview) => review.full && ACCEPTING.has(review.status)
|
|
117
121
|
&& (review.basePackage === null || (review.basePackage === pkg.name && (bases === 'any' || bases.includes(review.baseVersion ?? ''))))
|
|
118
122
|
let commented = 0, passing = 0, rejected = false
|
|
123
|
+
const reviewers: Reviewer[] = []
|
|
119
124
|
for (const theirs of by.values()) {
|
|
120
125
|
const latest = theirs.reduce((one, other) => other.time > one.time ? other : one)
|
|
126
|
+
const passes = ACCEPTING.has(latest.status) && theirs.some(qualifies)
|
|
121
127
|
// In its comments alone where those telling where they stand are: those
|
|
122
128
|
// passing it, where they pass it, else their latest.
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
129
|
+
const comment = (passes ? theirs.filter(qualifies) : [latest]).every(review => review.comment)
|
|
130
|
+
if (comment) commented++
|
|
125
131
|
if (latest.status === 'reject') rejected = true
|
|
126
|
-
else if (
|
|
132
|
+
else if (passes) passing++
|
|
133
|
+
reviewers.push({ login: latest.login, status: latest.status, ...passes ? { passes: true as const } : {}, ...latest.comment ? { comment: true as const } : {} })
|
|
127
134
|
}
|
|
128
|
-
return { passing, rejected, commented }
|
|
135
|
+
return { passing, rejected, commented, reviewers }
|
|
129
136
|
}
|
|
137
|
+
type Reviewer = { login: string; status: PublishedReview['status']; passes?: true; comment?: true }
|
|
130
138
|
|
|
131
139
|
// A version as the client is told it: what its reviews are held to left
|
|
132
140
|
// out, but whether it is patched.
|
|
@@ -146,7 +154,8 @@ function askedOf(pkg: LockedPackage): [string, ReviewedDependency] {
|
|
|
146
154
|
|
|
147
155
|
// A version's review, and whether it wants one (`wants`), neither passing
|
|
148
156
|
// nor needing none, as only one that can be reviewed may.
|
|
149
|
-
type Review = { unsupported: Unsupported } | { baseline: true } | { reviewed: true } | { reviews: number; rejected?: true; commented?: number
|
|
157
|
+
type Review = { unsupported: Unsupported } | { baseline: true } | { reviewed: true } | { reviews: number; rejected?: true; commented?: number; reviewers?: Reviewer[] }
|
|
158
|
+
| Record<string, never>
|
|
150
159
|
interface Judged { review: Review; wants: boolean }
|
|
151
160
|
type Judge = (pkg: LockedPackage, bases: string[] | 'any') => Judged
|
|
152
161
|
interface Context { state: RepoReviewState; reviews: ReviewsOf; baseline: Set<string> | null }
|
|
@@ -155,8 +164,8 @@ function judge(pkg: LockedPackage, bases: string[] | 'any', { state, reviews, ba
|
|
|
155
164
|
if (as === 'skip') return { review: {}, wants: false }
|
|
156
165
|
if (as !== 'version') return { review: { unsupported: as }, wants: false }
|
|
157
166
|
if (baselineKeys(pkg).some(key => baseline?.has(key))) return { review: { baseline: true }, wants: false }
|
|
158
|
-
const { passing, rejected, commented } = stancesOf(pkg, reviews, bases)
|
|
159
|
-
const told = { reviews: passing, ...commented > 0 ? { commented } : {} }
|
|
167
|
+
const { passing, rejected, commented, reviewers } = stancesOf(pkg, reviews, bases)
|
|
168
|
+
const told = { reviews: passing, ...commented > 0 ? { commented } : {}, ...reviewers.length > 0 ? { reviewers } : {} }
|
|
160
169
|
if (rejected) return { review: { ...told, rejected: true }, wants: true }
|
|
161
170
|
return { review: told, wants: passing < state.required }
|
|
162
171
|
}
|
|
@@ -306,10 +315,11 @@ export const reviewedPackages = (told: PullDependencies) => told.lockfiles
|
|
|
306
315
|
// A repository's baseline as read for a pull request: its versions and the
|
|
307
316
|
// lockfiles there left unread; none where it reviews since no commit; and
|
|
308
317
|
// where GitHub doesn't read it, none and why.
|
|
309
|
-
export async function baselineFor(fullName: string, token: string, repoId: number, state: RepoReviewState, kept: ReviewBaselineStore,
|
|
318
|
+
export async function baselineFor(fullName: string, token: string, repoId: number, state: RepoReviewState, kept: ReviewBaselineStore, reads: LockfileStore | null,
|
|
319
|
+
asked?: GithubAsked) {
|
|
310
320
|
if (!state.since) return { baseline: null, unread: [], error: null }
|
|
311
321
|
try {
|
|
312
|
-
const { versions, unread } = await reviewBaseline(fullName, token, repoId, state.since, kept, asked)
|
|
322
|
+
const { versions, unread } = await reviewBaseline(fullName, token, repoId, state.since, kept, reads, asked)
|
|
313
323
|
return { baseline: versions, unread, error: null }
|
|
314
324
|
} catch (err) {
|
|
315
325
|
if (err instanceof GithubApiError && err.status !== 429 && err.status !== 401) return { baseline: null, unread: [], error: err.message }
|