@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
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// The package versions a pull request's lockfiles lock that they didn't
|
|
2
|
+
// before it, as the code viewer's Pull requests tab shows them on an open
|
|
3
|
+
// one's tile and its popup on any (GET
|
|
4
|
+
// /api/admin/repositories/pull-dependencies): read only where it changes a
|
|
5
|
+
// lockfile of npm's ecosystem that @preventive/lockfile reads
|
|
6
|
+
// (package-lock.json and npm-shrinkwrap.json of `lockfileVersion: 3`,
|
|
7
|
+
// pnpm-lock.yaml of pnpm 9 to 12 and Yarn 1's yarn.lock, locked-packages.ts),
|
|
8
|
+
// and Bun's, which it doesn't (bun.lock and bun.lockb, its directory told
|
|
9
|
+
// `lockfile-unsupported`); none under a node_modules nor a test's fixtures
|
|
10
|
+
// (isTestFixture), which no install takes. Its files are GitHub's comparison of its base with its head
|
|
11
|
+
// (GET /repos/:owner/:repo/compare/:base...:head), which names where its
|
|
12
|
+
// branch left its base, its merge base, as GitHub's own Files changed
|
|
13
|
+
// compares from it, and lists as many as a commit's files are read
|
|
14
|
+
// (repository-changes.ts MAX_FILES, 300, the most it lists); one changing as
|
|
15
|
+
// many, from its own list of them, a page at a time, as many as PULL_FILES.
|
|
16
|
+
// Each directory's lockfiles it changes are read at its merge base and at its
|
|
17
|
+
// head, a lockfile replaced by another, Yarn's by pnpm's say, read as the
|
|
18
|
+
// one it replaced; a version new to a directory is one its lockfiles lock at
|
|
19
|
+
// the head that none of them locked at the merge base, from where it is
|
|
20
|
+
// installed: a package new to it, or a version of one they locked others of,
|
|
21
|
+
// a bump's, told with those (`was`). What a pair of commits holds never
|
|
22
|
+
// changes, so what is told of one is kept by them (`kept`), and each
|
|
23
|
+
// lockfile read is kept in this instance's memory by its commit
|
|
24
|
+
// (`lockfileReads`): the pull requests branching off the same commit, as a
|
|
25
|
+
// bot's do, read their base's once.
|
|
26
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
27
|
+
import { GithubApiError, githubJson } from './github-app.ts'
|
|
28
|
+
import { upstreamGithub } from './github-errors.ts'
|
|
29
|
+
import { compareVersions, isExactVersion } from '@preventive/upstream/semver.js'
|
|
30
|
+
import { type LockedPackage, isTestFixture, lockedPackages } from './locked-packages.ts'
|
|
31
|
+
import { type Lru, lru } from './lru.ts'
|
|
32
|
+
import { type ChangedFile, commitChanges } from './repository-changes.ts'
|
|
33
|
+
|
|
34
|
+
// The files of a pull request read from its own list past those compared,
|
|
35
|
+
// FILES_PAGE a page, as many as GitHub lists (3,000).
|
|
36
|
+
const FILES_PAGE = 100
|
|
37
|
+
export const PULL_FILES = 3000
|
|
38
|
+
// The directories whose lockfiles are read, those past them left out
|
|
39
|
+
// (`cut`), and the packages told new of each, those past them counted.
|
|
40
|
+
export const MAX_DIRECTORIES = 20
|
|
41
|
+
export const MAX_ADDED = 200
|
|
42
|
+
// The versions a directory's lockfiles lock at the head kept with what they
|
|
43
|
+
// add, as many as MAX_LOCKED, those past them counted, for those not new to
|
|
44
|
+
// be reviewed as well (pull-dependency-reviews.ts).
|
|
45
|
+
export const MAX_LOCKED = 20_000
|
|
46
|
+
// Lockfiles read at once.
|
|
47
|
+
const READ_PARALLEL = 4
|
|
48
|
+
// The rules what is told was told by, kept with it (http.ts
|
|
49
|
+
// pullDependencyKept): one told by others is told again.
|
|
50
|
+
export const PULL_DEPENDENCIES_READ = 3
|
|
51
|
+
const SHA = /^[a-f\d]{40}$/u
|
|
52
|
+
|
|
53
|
+
type Format = 'npm' | 'pnpm' | 'yarn' | 'bun'
|
|
54
|
+
|
|
55
|
+
// The format of the lockfile of npm's ecosystem at `path`, as
|
|
56
|
+
// findRepoLockfiles names it (with pnpm's of a branch,
|
|
57
|
+
// pnpm-lock.<branch>.yaml, and of before pnpm 3, shrinkwrap.yaml, which its
|
|
58
|
+
// reader refuses); null for another file, and for one under a node_modules or
|
|
59
|
+
// of a test's fixtures. Bun's alone isn't read.
|
|
60
|
+
export function lockfileFormat(path: string): Format | null {
|
|
61
|
+
const dirs = path.split('/')
|
|
62
|
+
const name = dirs.pop() ?? ''
|
|
63
|
+
if (dirs.includes('node_modules') || isTestFixture(path)) return null
|
|
64
|
+
if (name === 'package-lock.json' || name === 'npm-shrinkwrap.json') return 'npm'
|
|
65
|
+
if (/^pnpm-lock(?:\..+)?\.yaml$/u.test(name) || name === 'shrinkwrap.yaml') return 'pnpm'
|
|
66
|
+
if (name === 'bun.lock' || name === 'bun.lockb') return 'bun'
|
|
67
|
+
return name === 'yarn.lock' ? 'yarn' : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Whether a pull request changing `paths` changes a lockfile read here: null
|
|
71
|
+
// where none of them is one but they are fewer than it changes
|
|
72
|
+
// (`changed`), which it may yet.
|
|
73
|
+
export function touchesLockfile(paths: string[], changed: number): boolean | null {
|
|
74
|
+
if (paths.some(path => lockfileFormat(path) !== null)) return true
|
|
75
|
+
return changed > paths.length ? null : false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A package version new to a directory, as its lockfile locks it, with its
|
|
79
|
+
// integrity, which its reviews are held to; of a package its lockfiles locked
|
|
80
|
+
// before, the versions of it they did (`was`, the highest first), none of one
|
|
81
|
+
// new to it.
|
|
82
|
+
export type AddedPackage = LockedPackage & { was?: string[] }
|
|
83
|
+
// A directory's lockfiles the pull request changes (`paths`, those it adds,
|
|
84
|
+
// modifies, removes or renames, by either name): the package versions they
|
|
85
|
+
// lock new to it, by name and version (as many as MAX_LOCKED, told as many
|
|
86
|
+
// as MAX_ADDED, `more` past them), and all they lock (`locked`, as many as MAX_LOCKED, how many more
|
|
87
|
+
// `moreLocked`); or why they couldn't be told, a lockfile's reader refusing one
|
|
88
|
+
// (`lockfile-unreadable`, with what it said), one of a format not read
|
|
89
|
+
// (`lockfile-unsupported`, Bun's) or GitHub having none at a commit compared.
|
|
90
|
+
export type DirectoryDependencies = { paths: string[] } & ({ added: AddedPackage[]; more: number; locked: LockedPackage[]; moreLocked: number }
|
|
91
|
+
| { error: string; detail?: string })
|
|
92
|
+
// What a pull request's lockfiles add: the commits compared (`base`, its
|
|
93
|
+
// merge base, and `head`); each directory's, of those it changes a lockfile
|
|
94
|
+
// in; and `cut` where it changes more files than GitHub lists, or lockfiles
|
|
95
|
+
// in more directories than are read, with, where only those cut it and they
|
|
96
|
+
// are few enough, the paths of those directories' lockfiles (`omitted`), for
|
|
97
|
+
// a review holding to none of them to fail in nothing.
|
|
98
|
+
export interface PullDependencies { base: string; head: string; lockfiles: DirectoryDependencies[]; cut: boolean; omitted?: string[] }
|
|
99
|
+
const MAX_OMITTED = 1000
|
|
100
|
+
|
|
101
|
+
// What is told of a pull request, by the commits it is of, `${base}...${head}`.
|
|
102
|
+
export interface PullDependencyStore {
|
|
103
|
+
read(key: string): Promise<PullDependencies | null>
|
|
104
|
+
write(key: string, value: PullDependencies): Promise<void>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A lockfile's packages, or why its reader refused it, by its repository,
|
|
108
|
+
// commit and path, read in this instance; by the packages each holds, as the
|
|
109
|
+
// scan's are (repository-risk.ts riskCaches).
|
|
110
|
+
type LockfileRead = { packages: LockedPackage[] } | { error: string; detail?: string }
|
|
111
|
+
const lockfileReads: Lru<LockfileRead> = lru(200_000, read => 'packages' in read ? read.packages.length + 1 : 1)
|
|
112
|
+
|
|
113
|
+
const api = (fullName: string) => `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
|
|
114
|
+
|
|
115
|
+
// The pull request's base's and head's commits, as GitHub has them now: its
|
|
116
|
+
// base's moving on as it does, its head's with each push, both kept once it
|
|
117
|
+
// is merged or closed.
|
|
118
|
+
async function pullCommits(fullName: string, token: string, number: number, fetchImpl: typeof fetch) {
|
|
119
|
+
const pull = await githubJson(`${api(fullName)}/pulls/${number}`, token, fetchImpl) as
|
|
120
|
+
{ number?: unknown; base?: { sha?: unknown; repo?: { full_name?: unknown } | null }; head?: { sha?: unknown } } | null
|
|
121
|
+
const base = pull?.base?.sha, head = pull?.head?.sha, repo = pull?.base?.repo?.full_name
|
|
122
|
+
if (pull?.number !== number || typeof base !== 'string' || !SHA.test(base) || typeof head !== 'string' || !SHA.test(head)
|
|
123
|
+
|| typeof repo !== 'string' || repo.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(502, 'github-malformed')
|
|
124
|
+
return { base, head }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The pull request's merge base and the files it changes from it: as GitHub
|
|
128
|
+
// compares its base with its head, else, where that lists as many as it lists
|
|
129
|
+
// at most, from the pull request's own list, a page at a time; `cut` where
|
|
130
|
+
// that lists as many as it lists at most, too.
|
|
131
|
+
async function pullFiles(fullName: string, token: string, number: number, base: string, head: string, fetchImpl: typeof fetch) {
|
|
132
|
+
const compared = await githubJson(`${api(fullName)}/compare/${base}...${head}?per_page=1`, token, fetchImpl) as { merge_base_commit?: { sha?: unknown } } | null
|
|
133
|
+
const mergeBase = compared?.merge_base_commit?.sha
|
|
134
|
+
if (typeof mergeBase !== 'string' || !SHA.test(mergeBase)) throw new GithubApiError(502, 'github-malformed')
|
|
135
|
+
const { files, more } = commitChanges(compared)
|
|
136
|
+
if (!more) return { mergeBase, files, cut: false }
|
|
137
|
+
const listed: ChangedFile[] = []
|
|
138
|
+
for (let page = 1; listed.length < PULL_FILES; page++) {
|
|
139
|
+
const data = await githubJson(`${api(fullName)}/pulls/${number}/files?per_page=${FILES_PAGE}&page=${page}`, token, fetchImpl)
|
|
140
|
+
if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
|
|
141
|
+
listed.push(...commitChanges({ files: data }).files)
|
|
142
|
+
if (data.length < FILES_PAGE) return { mergeBase, files: listed, cut: false }
|
|
143
|
+
}
|
|
144
|
+
return { mergeBase, files: listed, cut: true }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The lockfiles read here a pull request changes (`files`), by directory:
|
|
148
|
+
// each as it was (`before`, one modified or removed, or a rename's path
|
|
149
|
+
// before) and as it is (`after`, one added or modified, or a rename's path
|
|
150
|
+
// now), and every path of them (`paths`), as many directories as
|
|
151
|
+
// MAX_DIRECTORIES.
|
|
152
|
+
function lockfilesChanged(files: ChangedFile[]) {
|
|
153
|
+
type Directory = { before: { path: string; format: Format }[]; after: { path: string; format: Format }[]; paths: Set<string> }
|
|
154
|
+
const directories = new Map<string, Directory>()
|
|
155
|
+
const take = (side: 'before' | 'after', path: string | undefined) => {
|
|
156
|
+
const format = path ? lockfileFormat(path) : null
|
|
157
|
+
if (!path || !format) return
|
|
158
|
+
const dir = path.slice(0, Math.max(path.lastIndexOf('/'), 0))
|
|
159
|
+
const directory = directories.get(dir) ?? directories.set(dir, { before: [], after: [], paths: new Set() }).get(dir)!
|
|
160
|
+
directory[side].push({ path, format })
|
|
161
|
+
directory.paths.add(path)
|
|
162
|
+
}
|
|
163
|
+
for (const [path, status, from] of files) {
|
|
164
|
+
take('before', status === 'R' ? from : status === 'A' ? undefined : path)
|
|
165
|
+
take('after', status === 'D' ? undefined : path)
|
|
166
|
+
}
|
|
167
|
+
const all = [...directories.entries()].toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
168
|
+
return { directories: all.slice(0, MAX_DIRECTORIES).map(([, directory]) => directory), cut: all.length > MAX_DIRECTORIES,
|
|
169
|
+
omitted: all.slice(MAX_DIRECTORIES).flatMap(([, directory]) => [...directory.paths]).toSorted() }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A lockfile's packages at `ref`, as read in this instance, else of GitHub;
|
|
173
|
+
// a lockfile its reader refuses, why. Any failure to read it of GitHub but
|
|
174
|
+
// its having none there is the whole answer's, as it would be every file's.
|
|
175
|
+
export async function readLockfile(fullName: string, token: string, ref: string, { path, format }: { path: string; format: Format }): Promise<LockfileRead> {
|
|
176
|
+
if (format === 'bun') return { error: 'lockfile-unsupported' }
|
|
177
|
+
const key = `${fullName}\0${ref}\0${path}`
|
|
178
|
+
const known = lockfileReads.get(key)
|
|
179
|
+
if (known) return known
|
|
180
|
+
let text: string
|
|
181
|
+
try {
|
|
182
|
+
text = await upstreamGithub(() => createClient({ token, userAgent: 'deepview-triage' }).getRepoFile({ repo: fullName, path, ref }))
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err instanceof GithubApiError && err.message === 'github-not-found') return { error: 'github-not-found' }
|
|
185
|
+
throw err
|
|
186
|
+
}
|
|
187
|
+
let read: LockfileRead
|
|
188
|
+
try { read = { packages: lockedPackages(format, text, { review: true }) } } catch (error) {
|
|
189
|
+
read = { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
|
|
190
|
+
}
|
|
191
|
+
lockfileReads.set(key, read)
|
|
192
|
+
return read
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Runs `run` for each of `items`, as many at once as `parallel`, each's
|
|
196
|
+
// result in its place.
|
|
197
|
+
export async function eachOf<T, R>(items: T[], parallel: number, run: (item: T) => Promise<R>): Promise<R[]> {
|
|
198
|
+
const results: R[] = []
|
|
199
|
+
let next = 0
|
|
200
|
+
await Promise.all(Array.from({ length: Math.min(parallel, items.length) }, async () => {
|
|
201
|
+
while (next < items.length) {
|
|
202
|
+
const at = next++
|
|
203
|
+
results[at] = await run(items[at]!)
|
|
204
|
+
}
|
|
205
|
+
}))
|
|
206
|
+
return results
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Versions by semver, the highest first, any semver can't read after them.
|
|
210
|
+
function byVersion(a: string, b: string) {
|
|
211
|
+
const [exactA, exactB] = [isExactVersion(a), isExactVersion(b)]
|
|
212
|
+
if (exactA && exactB) return compareVersions(b, a)
|
|
213
|
+
if (exactA === exactB) return a < b ? -1 : a > b ? 1 : 0
|
|
214
|
+
return exactA ? -1 : 1
|
|
215
|
+
}
|
|
216
|
+
const byName = (a: AddedPackage, b: AddedPackage) => a.name < b.name ? -1 : a.name > b.name ? 1 : -byVersion(a.version ?? '', b.version ?? '')
|
|
217
|
+
// A package as it is installed: its name and version, where from, and its
|
|
218
|
+
// patch, a patched version's new where its patch is; and as it is locked,
|
|
219
|
+
// with what its reviews are held to too.
|
|
220
|
+
export const lockedAs = (pkg: LockedPackage) => `${pkg.source}\0${pkg.name}@${pkg.version ?? ''}\0${pkg.from ?? ''}\0${pkg.patchHash ?? ''}`
|
|
221
|
+
export const lockedWith = (pkg: LockedPackage) => `${lockedAs(pkg)}\0${pkg.integrity ?? ''}\0${pkg.shasum ?? ''}\0${pkg.lockfile ?? ''}`
|
|
222
|
+
|
|
223
|
+
// What a directory's lockfiles add, from each read before and after: the
|
|
224
|
+
// package versions after none before has, each once, by name and version,
|
|
225
|
+
// each with the versions of its name before, those past MAX_ADDED counted
|
|
226
|
+
// (`more`), not told but reviewed as they are; and every one after, each
|
|
227
|
+
// integrity it is locked with (`locked`), as many as MAX_LOCKED; or the first
|
|
228
|
+
// failure among them.
|
|
229
|
+
function addedIn(paths: string[], before: LockfileRead[], after: LockfileRead[]): DirectoryDependencies {
|
|
230
|
+
const failed = [...before, ...after].find(read => 'error' in read)
|
|
231
|
+
if (failed && 'error' in failed) return { paths, error: failed.error, ...failed.detail ? { detail: failed.detail } : {} }
|
|
232
|
+
const known = new Set<string>()
|
|
233
|
+
const versions = new Map<string, Set<string>>()
|
|
234
|
+
for (const pkg of before.flatMap(read => 'packages' in read ? read.packages : [])) {
|
|
235
|
+
known.add(lockedAs(pkg))
|
|
236
|
+
const of = versions.get(pkg.name) ?? versions.set(pkg.name, new Set()).get(pkg.name)!
|
|
237
|
+
if (pkg.version) of.add(pkg.version)
|
|
238
|
+
}
|
|
239
|
+
const added = new Map<string, AddedPackage>()
|
|
240
|
+
const locked = new Map<string, LockedPackage>()
|
|
241
|
+
for (const read of after) {
|
|
242
|
+
for (const pkg of 'packages' in read ? read.packages : []) {
|
|
243
|
+
const key = lockedAs(pkg)
|
|
244
|
+
const held = locked.get(lockedWith(pkg))
|
|
245
|
+
if (!held) locked.set(lockedWith(pkg), { ...pkg })
|
|
246
|
+
else if (held.dev && !pkg.dev) delete held.dev
|
|
247
|
+
if (known.has(key)) continue
|
|
248
|
+
const had = added.get(key)
|
|
249
|
+
const was = versions.get(pkg.name)
|
|
250
|
+
if (!had) added.set(key, { ...pkg, ...was ? { was: [...was].toSorted(byVersion) } : {} })
|
|
251
|
+
else if (had.dev && !pkg.dev) delete had.dev
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const all = [...added.values()].toSorted(byName)
|
|
255
|
+
return { paths, added: all.slice(0, MAX_LOCKED), more: Math.max(0, all.length - MAX_ADDED),
|
|
256
|
+
locked: [...locked.values()].slice(0, MAX_LOCKED), moreLocked: Math.max(0, locked.size - MAX_LOCKED) }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// What pull request `number` adds to its lockfiles' packages (PullDependencies),
|
|
260
|
+
// kept by its base's and head's commits, else read of GitHub and kept; none
|
|
261
|
+
// where it changes no lockfile read here.
|
|
262
|
+
export async function pullDependencies(fullName: string, token: string, number: number, kept: PullDependencyStore,
|
|
263
|
+
fetchImpl: typeof fetch = globalThis.fetch): Promise<PullDependencies> {
|
|
264
|
+
const { base, head } = await pullCommits(fullName, token, number, fetchImpl)
|
|
265
|
+
const key = `${base}...${head}`
|
|
266
|
+
const held = await kept.read(key)
|
|
267
|
+
if (held) return held
|
|
268
|
+
const { mergeBase, files, cut: filesCut } = await pullFiles(fullName, token, number, base, head, fetchImpl)
|
|
269
|
+
const { directories, cut, omitted } = lockfilesChanged(files)
|
|
270
|
+
const reads = directories.flatMap(({ before, after }) => [...before.map(lockfile => [mergeBase, lockfile] as const), ...after.map(lockfile => [head, lockfile] as const)])
|
|
271
|
+
const read = await eachOf(reads, READ_PARALLEL, ([ref, lockfile]) => readLockfile(fullName, token, ref, lockfile))
|
|
272
|
+
let at = 0
|
|
273
|
+
const lockfiles = directories.map(({ before, after, paths }) => {
|
|
274
|
+
const readBefore = read.slice(at, at += before.length)
|
|
275
|
+
return addedIn([...paths].toSorted(), readBefore, read.slice(at, at += after.length))
|
|
276
|
+
})
|
|
277
|
+
const told = { base: mergeBase, head, lockfiles, cut: filesCut || cut, ...cut && !filesCut && omitted.length <= MAX_OMITTED ? { omitted } : {} }
|
|
278
|
+
await kept.write(key, told)
|
|
279
|
+
return told
|
|
280
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// What an open pull request's lockfiles' versions want of review, in a
|
|
2
|
+
// repository that reviews dependencies (repository-dependency-reviews.ts), as
|
|
3
|
+
// its tile and popup show it: each version it adds, whether it passes, by as
|
|
4
|
+
// many reviewers' reviews as the repository requires (`reviews`) or by the
|
|
5
|
+
// reviewed dependencies the host registers (`reviewed`,
|
|
6
|
+
// reviewed-dependencies.ts), or needs none, being among those the
|
|
7
|
+
// repository's lockfiles locked at the commit it is reviewed since
|
|
8
|
+
// (`baseline`); apart from them, the other versions its lockfiles lock that
|
|
9
|
+
// neither passes nor needs none (`unreviewed`); and those that can't be
|
|
10
|
+
// reviewed (`unsupported`), which fail its review, as a lockfile it changes
|
|
11
|
+
// that isn't read does. Without that commit, every version wants its
|
|
12
|
+
// reviews; registered reviewed dependencies replace it.
|
|
13
|
+
//
|
|
14
|
+
// A version is reviewed as npm's registry has it, by its name, version and
|
|
15
|
+
// integrity: one locked of npm's registry, by its name or its link to its
|
|
16
|
+
// tarball (locked-packages.ts), with an integrity. One from git, another
|
|
17
|
+
// tarball (a mirror's too), or with no integrity can't be; a file of the
|
|
18
|
+
// project's isn't. Its reviews are those of it, of an integrity it is locked
|
|
19
|
+
// with, either whole, or compared with a version of its package: for a
|
|
20
|
+
// version the pull request adds, one it was; for another, any. A patched one
|
|
21
|
+
// (`patched`) passes by none of them, the app reviewing no patch yet: only
|
|
22
|
+
// by the reviewed dependencies, or locked so, its patch too, at that commit.
|
|
23
|
+
// Which versions the repository's lockfiles locked at that commit never
|
|
24
|
+
// changes: it is read once, every lockfile there of those read here
|
|
25
|
+
// (pull-dependencies.ts), and kept by the commit (`baselines` in this
|
|
26
|
+
// instance, `kept` beyond it), never stored as reviews.
|
|
27
|
+
import { lockedAt, lockedIn } from './commit-lockfiles.ts'
|
|
28
|
+
import { GithubApiError } from './github-app.ts'
|
|
29
|
+
import type { LockedPackage } from './locked-packages.ts'
|
|
30
|
+
import { EVERY_LOCKFILE, type LockfileScope, heldTo, isEvery, reviewedIn } from './lockfile-scope.ts'
|
|
31
|
+
import { type Lru, lru } from './lru.ts'
|
|
32
|
+
import { type DirectoryDependencies, MAX_ADDED, type PullDependencies, lockedAs, lockedWith } from './pull-dependencies.ts'
|
|
33
|
+
import type { PublishedReview, RepoReviewState } from './repository-dependency-reviews.ts'
|
|
34
|
+
import { type ReviewedDependencies, type ReviewedDependency, askReviewed } from './reviewed-dependencies.ts'
|
|
35
|
+
|
|
36
|
+
// The other versions told unreviewed, and those told unsupported, of each
|
|
37
|
+
// directory, those past them counted.
|
|
38
|
+
export const MAX_UNREVIEWED = 200
|
|
39
|
+
|
|
40
|
+
// The versions a commit's lockfiles lock, and the lockfiles there that
|
|
41
|
+
// couldn't be read (`unread`), whose versions want reviews all the same.
|
|
42
|
+
export interface Baseline { versions: Set<string>; unread: string[] }
|
|
43
|
+
export interface ReviewBaselineStore {
|
|
44
|
+
read(key: string): Promise<{ versions: string[]; unread: string[] } | null>
|
|
45
|
+
write(key: string, value: { versions: string[]; unread: string[] }): Promise<void>
|
|
46
|
+
}
|
|
47
|
+
const baselines: Lru<Baseline> = lru(500_000, baseline => baseline.versions.size + baseline.unread.length + 1)
|
|
48
|
+
|
|
49
|
+
// Why a version can't be reviewed: from git, of a tarball not npm's
|
|
50
|
+
// registry's of its version, or locked with no integrity.
|
|
51
|
+
export type Unsupported = 'git' | 'tarball' | 'integrity'
|
|
52
|
+
// How a version is reviewed: by its name, version and integrity
|
|
53
|
+
// (`version`), not at all (`skip`), or why it can't be.
|
|
54
|
+
function reviewedAs(pkg: LockedPackage): 'version' | 'skip' | Unsupported {
|
|
55
|
+
if (pkg.source === 'file') return 'skip'
|
|
56
|
+
if (pkg.source === 'git') return 'git'
|
|
57
|
+
if (pkg.source !== 'registry' || pkg.version === null) return 'tarball'
|
|
58
|
+
return pkg.integrity ? 'version' : 'integrity'
|
|
59
|
+
}
|
|
60
|
+
// The hashes an integrity names, each of which is npm's of it.
|
|
61
|
+
const hashesOf = (pkg: LockedPackage) => (pkg.integrity ?? '').split(/\s+/u).filter(Boolean)
|
|
62
|
+
// A version as a baseline holds it: its name and version with each hash,
|
|
63
|
+
// and its patch.
|
|
64
|
+
const baselineKeys = (pkg: LockedPackage) => hashesOf(pkg).map(hash => `${pkg.name}@${pkg.version ?? ''}\0${hash}\0${pkg.patchHash ?? ''}`)
|
|
65
|
+
|
|
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 reader refuses, isn't one of, or
|
|
69
|
+
// past those read (`unread`). GitHub having none of a lockfile it lists
|
|
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> {
|
|
72
|
+
const key = `${repoId}\0${since}`
|
|
73
|
+
const known = baselines.get(key)
|
|
74
|
+
if (known) return known
|
|
75
|
+
const stored = await kept.read(key)
|
|
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)
|
|
78
|
+
const { packages } = lockedIn(read), { unread } = read
|
|
79
|
+
const versions = new Set<string>()
|
|
80
|
+
for (const pkg of packages) if (reviewedAs(pkg) === 'version') for (const held of baselineKeys(pkg)) versions.add(held)
|
|
81
|
+
const baseline = { versions, unread }
|
|
82
|
+
baselines.set(key, baseline)
|
|
83
|
+
await kept.write(key, { versions: [...versions], unread: baseline.unread })
|
|
84
|
+
return baseline
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Passing reviews by the version they are of, its name and version.
|
|
88
|
+
type ReviewsOf = Map<string, PublishedReview[]>
|
|
89
|
+
function reviewsByVersion(reviews: PublishedReview[]): ReviewsOf {
|
|
90
|
+
const by: ReviewsOf = new Map()
|
|
91
|
+
for (const review of reviews) {
|
|
92
|
+
const key = `${review.package}@${review.version}`
|
|
93
|
+
const held = by.get(key)
|
|
94
|
+
if (held) held.push(review)
|
|
95
|
+
else by.set(key, [review])
|
|
96
|
+
}
|
|
97
|
+
return by
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ACCEPTING = new Set(['accept', 'accept-with-nits'])
|
|
101
|
+
// Where `pkg`'s reviewers stand on it among `reviews`, those of its version,
|
|
102
|
+
// of an integrity it is locked with, and of its patch: each one's latest
|
|
103
|
+
// telling it, any rejecting it holding it unreviewed (`rejected`); those
|
|
104
|
+
// accepting it passing it (`passing`), each once, by a full, accepting
|
|
105
|
+
// review of theirs, whole or compared with one of `bases` of its package
|
|
106
|
+
// (any, for `any`); and how many review it in its pull request's comments
|
|
107
|
+
// alone (`commented`), which count for it alone: whose reviews passing it,
|
|
108
|
+
// else whose latest, are its comments'.
|
|
109
|
+
function stancesOf(pkg: LockedPackage, reviews: ReviewsOf, bases: string[] | 'any') {
|
|
110
|
+
const hashes = hashesOf(pkg)
|
|
111
|
+
const by = new Map<string, PublishedReview[]>()
|
|
112
|
+
for (const review of reviews.get(`${pkg.name}@${pkg.version}`) ?? []) {
|
|
113
|
+
if (!hashes.includes(review.integrity) || (review.patchHash ?? '') !== (pkg.patchHash ?? '')) continue
|
|
114
|
+
by.set(review.reviewer, [...by.get(review.reviewer) ?? [], review])
|
|
115
|
+
}
|
|
116
|
+
const qualifies = (review: PublishedReview) => review.full && ACCEPTING.has(review.status)
|
|
117
|
+
&& (review.basePackage === null || (review.basePackage === pkg.name && (bases === 'any' || bases.includes(review.baseVersion ?? ''))))
|
|
118
|
+
let commented = 0, passing = 0, rejected = false
|
|
119
|
+
for (const theirs of by.values()) {
|
|
120
|
+
const latest = theirs.reduce((one, other) => other.time > one.time ? other : one)
|
|
121
|
+
// In its comments alone where those telling where they stand are: those
|
|
122
|
+
// passing it, where they pass it, else their latest.
|
|
123
|
+
const telling = ACCEPTING.has(latest.status) && theirs.some(qualifies) ? theirs.filter(qualifies) : [latest]
|
|
124
|
+
if (telling.every(review => review.comment)) commented++
|
|
125
|
+
if (latest.status === 'reject') rejected = true
|
|
126
|
+
else if (ACCEPTING.has(latest.status) && theirs.some(qualifies)) passing++
|
|
127
|
+
}
|
|
128
|
+
return { passing, rejected, commented }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A version as the client is told it: what its reviews are held to left
|
|
132
|
+
// out, but whether it is patched.
|
|
133
|
+
function shown<T extends LockedPackage>(pkg: T): Omit<T, 'integrity' | 'shasum' | 'patchHash' | 'lockfile'> & { patched?: true } {
|
|
134
|
+
const copy = { ...pkg }
|
|
135
|
+
for (const field of ['integrity', 'shasum', 'patchHash', 'lockfile'] as const) delete copy[field]
|
|
136
|
+
return { ...copy, ...pkg.patchHash ? { patched: true as const } : {} }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A version as the registered reviewed dependencies are asked of it, by
|
|
140
|
+
// what it is asked.
|
|
141
|
+
function askedOf(pkg: LockedPackage): [string, ReviewedDependency] {
|
|
142
|
+
const dependency: ReviewedDependency = { ecosystem: 'npm', lockfile: pkg.lockfile ?? 'npm', name: pkg.name, version: pkg.version ?? '', integrity: pkg.integrity ?? '',
|
|
143
|
+
...pkg.shasum ? { shasum: pkg.shasum } : {}, ...pkg.patchHash ? { patchHash: pkg.patchHash } : {} }
|
|
144
|
+
return [JSON.stringify(dependency), dependency]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A version's review, and whether it wants one (`wants`), neither passing
|
|
148
|
+
// 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 } | Record<string, never>
|
|
150
|
+
interface Judged { review: Review; wants: boolean }
|
|
151
|
+
type Judge = (pkg: LockedPackage, bases: string[] | 'any') => Judged
|
|
152
|
+
interface Context { state: RepoReviewState; reviews: ReviewsOf; baseline: Set<string> | null }
|
|
153
|
+
function judge(pkg: LockedPackage, bases: string[] | 'any', { state, reviews, baseline }: Context): Judged {
|
|
154
|
+
const as = reviewedAs(pkg)
|
|
155
|
+
if (as === 'skip') return { review: {}, wants: false }
|
|
156
|
+
if (as !== 'version') return { review: { unsupported: as }, wants: false }
|
|
157
|
+
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 } : {} }
|
|
160
|
+
if (rejected) return { review: { ...told, rejected: true }, wants: true }
|
|
161
|
+
return { review: told, wants: passing < state.required }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// A directory as the client is told it: what it adds, as many as MAX_ADDED,
|
|
165
|
+
// each version with its review (`review`, as judged), those past them judged
|
|
166
|
+
// as they are, against the versions they were, as is every one its lockfiles
|
|
167
|
+
// lock of them (another integrity of it, say), each told by the least passing
|
|
168
|
+
// of them, and those of all that want one (`toReview`); the others its lockfiles lock that want reviews, and every one
|
|
169
|
+
// that can't be reviewed (as many of each as MAX_UNREVIEWED, how many more,
|
|
170
|
+
// once `capped`); and how many it locks past those read (`moreLocked`), which
|
|
171
|
+
// aren't checked; without `review`, what it adds alone.
|
|
172
|
+
function reviewedDirectory(dir: DirectoryDependencies, review: Judge | null) {
|
|
173
|
+
if ('error' in dir) return dir
|
|
174
|
+
const { locked, moreLocked, added, ...told } = dir
|
|
175
|
+
if (!review) return { ...told, added: added.slice(0, MAX_ADDED).map(shown) }
|
|
176
|
+
const adds = new Map(added.map(pkg => [lockedAs(pkg), { pkg, variants: [pkg] }]))
|
|
177
|
+
for (const pkg of locked) {
|
|
178
|
+
const of = adds.get(lockedAs(pkg))
|
|
179
|
+
if (of && lockedWith(pkg) !== lockedWith(of.pkg)) of.variants.push(pkg)
|
|
180
|
+
}
|
|
181
|
+
const own = [...adds.values()].map(({ pkg, variants }) => {
|
|
182
|
+
const judged = variants.map(variant => ({ variant, ...review(variant, pkg.was ?? []) }))
|
|
183
|
+
const least = judged.find(one => 'unsupported' in one.review) ?? judged.find(one => one.wants) ?? judged[0]!
|
|
184
|
+
return { pkg, judged: least, wanting: judged.filter(one => one.wants).map(({ variant }) => variant) }
|
|
185
|
+
})
|
|
186
|
+
const others = locked.filter(pkg => !adds.has(lockedAs(pkg))).map(pkg => ({ pkg, judged: review(pkg, 'any') }))
|
|
187
|
+
const unsupported = locked.flatMap(pkg => { const as = reviewedAs(pkg); return as === 'version' || as === 'skip' ? [] : [{ ...shown(pkg), unsupported: as }] })
|
|
188
|
+
return { ...told, added: own.slice(0, MAX_ADDED).map(({ pkg, judged }) => ({ ...shown(pkg), ...judged.review })),
|
|
189
|
+
toReview: own.flatMap(({ wanting }) => wanting.map(shown)),
|
|
190
|
+
unreviewed: others.filter(({ judged }) => judged.wants).map(({ pkg, judged }) => ({ ...shown(pkg), ...judged.review })), unsupported, moreLocked }
|
|
191
|
+
}
|
|
192
|
+
function capped<T extends { toReview: unknown[]; unreviewed: unknown[]; unsupported: unknown[] }>({ toReview: _toReview, ...dir }: T) {
|
|
193
|
+
return { ...dir, unreviewed: dir.unreviewed.slice(0, MAX_UNREVIEWED), moreUnreviewed: Math.max(0, dir.unreviewed.length - MAX_UNREVIEWED),
|
|
194
|
+
unsupported: dir.unsupported.slice(0, MAX_UNREVIEWED), moreUnsupported: Math.max(0, dir.unsupported.length - MAX_UNREVIEWED) }
|
|
195
|
+
}
|
|
196
|
+
// How many versions of `lists` there are, each once across them, by `key`,
|
|
197
|
+
// as the client tells them once: one wanting reviews by its name and
|
|
198
|
+
// version, one that can't be reviewed by where it is from too.
|
|
199
|
+
type Shown = Pick<LockedPackage, 'name' | 'version' | 'source' | 'from'>
|
|
200
|
+
const distinct = (lists: Shown[][], key: (pkg: Shown) => string) => new Set(lists.flat().map(key)).size
|
|
201
|
+
const versionKey = (pkg: Shown) => `${pkg.name}@${pkg.version ?? ''}`
|
|
202
|
+
const sourceKey = (pkg: Shown) => `${pkg.source}\0${versionKey(pkg)}\0${pkg.from ?? ''}`
|
|
203
|
+
|
|
204
|
+
// The reviews an open pull request's comments make of `wanting`, the
|
|
205
|
+
// versions still wanting one (pull-comment-reviews.ts): those that count,
|
|
206
|
+
// and whether they couldn't be read.
|
|
207
|
+
export type CommentReviews = (wanting: ReviewedDependency[]) => Promise<{ reviews: PublishedReview[]; failed: boolean }>
|
|
208
|
+
|
|
209
|
+
// What a pull request's lockfiles lock, but in directories none of whose
|
|
210
|
+
// lockfiles `scope` holds to, as they aren't reviewed: left out, as lockfiles
|
|
211
|
+
// left unread are where it holds to none of them (`omitted`, told to none).
|
|
212
|
+
export function heldDependencies<T extends PullDependencies>(told: T, scope: LockfileScope): Omit<T, 'omitted'> {
|
|
213
|
+
const { omitted, ...answered } = told
|
|
214
|
+
if (isEvery(scope)) return answered
|
|
215
|
+
return { ...answered, cut: told.cut && !(omitted && !omitted.some(path => heldTo(scope, path))), lockfiles: told.lockfiles.filter(dir => reviewedIn(scope, dir.paths)) }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A pull request's dependencies as the client is told them: without the
|
|
219
|
+
// versions its directories lock; with `state`, a repository's review in
|
|
220
|
+
// effect, each version's review (reviewedDirectory), those wanting one asked
|
|
221
|
+
// of the registered reviewed dependencies (`reviewed`), and those still
|
|
222
|
+
// wanting one of the reviews its comments make (`commentReviews`, where the
|
|
223
|
+
// host reads them), and the review's terms (`review`), with how many
|
|
224
|
+
// reviewers' its comments count (`commentReviews`), or that they couldn't be
|
|
225
|
+
// read (`commentsFailed`), and how many versions across its directories want
|
|
226
|
+
// reviews, of those it adds (`toReview`) and the rest (`unreviewed`), and
|
|
227
|
+
// can't be reviewed (`unsupported`), each once; how many the reviewed dependencies failed to
|
|
228
|
+
// answer (`unasked`), and whether they are registered (`reviewedDependencies`,
|
|
229
|
+
// the commit reviewed since then none); and where the baseline couldn't be
|
|
230
|
+
// read, why (`baselineError`), every version then wanting its reviews, or
|
|
231
|
+
// the lockfiles there left unread (`baselineUnread`), whose versions do. It
|
|
232
|
+
// fails where a version can't be reviewed, a directory's lockfiles couldn't
|
|
233
|
+
// be read or lock more than are checked, or the pull request changes more
|
|
234
|
+
// lockfiles than are read (`cut`). Those of directories none of whose
|
|
235
|
+
// lockfiles the review holds to (`lockfiles`, lockfile-scope.ts, told in its
|
|
236
|
+
// terms where not every one) are left out (heldDependencies).
|
|
237
|
+
export async function reviewedDependencies(told: PullDependencies, state: RepoReviewState | null,
|
|
238
|
+
{ baseline = null, baselineUnread = [], baselineError = null, reviewed = null, commentReviews = null }: { baseline?: Set<string> | null; baselineUnread?: string[]
|
|
239
|
+
baselineError?: string | null; reviewed?: ReviewedDependencies | null; commentReviews?: CommentReviews | null } = {}) {
|
|
240
|
+
const scope = state?.lockfiles ?? EVERY_LOCKFILE
|
|
241
|
+
const answered = heldDependencies(told, scope)
|
|
242
|
+
if (!state) return { ...answered, lockfiles: answered.lockfiles.map(dir => reviewedDirectory(dir, null)) }
|
|
243
|
+
const { cut, lockfiles: held } = answered
|
|
244
|
+
let context = { state, reviews: reviewsByVersion(state.reviews), baseline: reviewed ? null : baseline }
|
|
245
|
+
// What wants a review, as `lookup` is asked of, each once.
|
|
246
|
+
const wanting = () => {
|
|
247
|
+
const asked = new Map<string, ReviewedDependency>()
|
|
248
|
+
const asking: Judge = (pkg, bases) => {
|
|
249
|
+
const judged = judge(pkg, bases, context)
|
|
250
|
+
if (judged.wants) asked.set(...askedOf(pkg))
|
|
251
|
+
return judged
|
|
252
|
+
}
|
|
253
|
+
for (const dir of held) reviewedDirectory(dir, asking)
|
|
254
|
+
return asked
|
|
255
|
+
}
|
|
256
|
+
const passed = new Set<string>()
|
|
257
|
+
let unasked = 0
|
|
258
|
+
const ask = async (asked: Map<string, ReviewedDependency>) => {
|
|
259
|
+
const answer = asked.size > 0 && reviewed ? await askReviewed(reviewed.lookup, asked) : { passed: new Set<string>(), unasked: 0 }
|
|
260
|
+
for (const key of answer.passed) passed.add(key)
|
|
261
|
+
unasked += answer.unasked
|
|
262
|
+
}
|
|
263
|
+
const asked = wanting()
|
|
264
|
+
await ask(asked)
|
|
265
|
+
// Of those still wanting one, and every other one the lockfiles it holds
|
|
266
|
+
// to add that its teams' reviews pass, telling other pull requests those
|
|
267
|
+
// it reviews (`commented`), but those `lookup` passes, reviewed, the
|
|
268
|
+
// reviews its comments make, an open one's alone (pull-comment-reviews.ts),
|
|
269
|
+
// which count as its teams' do; those their rejects hold asked of `lookup`
|
|
270
|
+
// too.
|
|
271
|
+
const parsing = new Map([...asked].filter(([key]) => !passed.has(key)))
|
|
272
|
+
if (commentReviews) {
|
|
273
|
+
const others = new Map<string, ReviewedDependency>()
|
|
274
|
+
for (const pkg of held.flatMap(dir => 'added' in dir ? dir.added : [])) {
|
|
275
|
+
const [key, dependency] = askedOf(pkg)
|
|
276
|
+
if (!asked.has(key) && 'reviews' in judge(pkg, pkg.was ?? [], context).review) others.set(key, dependency)
|
|
277
|
+
}
|
|
278
|
+
const vouched = others.size > 0 && reviewed ? (await askReviewed(reviewed.lookup, others)).passed : new Set<string>()
|
|
279
|
+
for (const [key, dependency] of others) if (!vouched.has(key)) parsing.set(key, dependency)
|
|
280
|
+
}
|
|
281
|
+
const comments = parsing.size > 0 && commentReviews ? await commentReviews([...parsing.values()]) : { reviews: [], failed: false }
|
|
282
|
+
if (comments.reviews.length > 0) {
|
|
283
|
+
context = { ...context, reviews: reviewsByVersion([...state.reviews, ...comments.reviews.map(review => ({ ...review, comment: true as const }))]) }
|
|
284
|
+
await ask(new Map([...wanting()].filter(([key]) => !asked.has(key))))
|
|
285
|
+
}
|
|
286
|
+
const dirs = held.map(dir => reviewedDirectory(dir, (pkg, bases) => {
|
|
287
|
+
const judged = judge(pkg, bases, context)
|
|
288
|
+
return judged.wants && passed.has(askedOf(pkg)[0]) ? { review: { reviewed: true }, wants: false } : judged
|
|
289
|
+
}))
|
|
290
|
+
const unread = baselineUnread.filter(path => heldTo(scope, path))
|
|
291
|
+
const listed = dirs.flatMap(dir => 'unreviewed' in dir ? [dir] : [])
|
|
292
|
+
return { ...answered, cut, lockfiles: dirs.map(dir => 'unreviewed' in dir ? capped(dir) : dir), review: { required: state.required,
|
|
293
|
+
since: reviewed ? null : state.since, canReview: state.canReview, ...reviewed ? { reviewedDependencies: true } : {},
|
|
294
|
+
toReview: distinct(listed.map(dir => dir.toReview), versionKey), unreviewed: distinct(listed.map(dir => dir.unreviewed), versionKey),
|
|
295
|
+
unsupported: distinct(listed.map(dir => dir.unsupported), sourceKey),
|
|
296
|
+
...unasked > 0 ? { unasked } : {}, ...baselineError && !reviewed ? { baselineError } : {},
|
|
297
|
+
...comments.reviews.length > 0 ? { commentReviews: new Set(comments.reviews.map(review => review.reviewer)).size } : {}, ...comments.failed ? { commentsFailed: true } : {},
|
|
298
|
+
...unread.length > 0 && !reviewed ? { baselineUnread: unread.slice(0, MAX_UNREVIEWED) } : {}, ...isEvery(scope) ? {} : { lockfiles: scope } } }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// The packages a pull request's lockfiles lock that its reviews here may
|
|
302
|
+
// pass, whose reviews are asked: those that can be reviewed, unpatched.
|
|
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))
|
|
305
|
+
|
|
306
|
+
// A repository's baseline as read for a pull request: its versions and the
|
|
307
|
+
// lockfiles there left unread; none where it reviews since no commit; and
|
|
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) {
|
|
310
|
+
if (!state.since) return { baseline: null, unread: [], error: null }
|
|
311
|
+
try {
|
|
312
|
+
const { versions, unread } = await reviewBaseline(fullName, token, repoId, state.since, kept)
|
|
313
|
+
return { baseline: versions, unread, error: null }
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (err instanceof GithubApiError && err.status !== 429 && err.status !== 401) return { baseline: null, unread: [], error: err.message }
|
|
316
|
+
throw err
|
|
317
|
+
}
|
|
318
|
+
}
|