@preventive/triage 1.0.0-alpha.50 → 1.0.0-alpha.52
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/managed/finding-detail.d.ts +2 -1
- package/common/managed/finding-detail.js +42 -11
- package/common/managed/finding-values.d.ts +2 -0
- package/common/managed/finding-values.js +44 -0
- package/common/managed/lite-name.d.ts +2 -0
- package/common/managed/lite-name.js +20 -0
- package/common/managed/npm-packages.d.ts +2 -0
- package/common/managed/npm-packages.js +18 -1
- package/common/managed/routes.js +19 -7
- package/common/managed/team-app.d.ts +1 -0
- package/common/managed/team-app.js +17 -1
- package/out/activity.js +4 -4
- package/out/{chunk-75YLHBCM.js → chunk-2K6UN3PC.js} +1 -1
- package/out/{chunk-HTBYHSPG.js → chunk-CHRBUGVN.js} +1 -1
- package/out/{chunk-4XMP7YVH.js → chunk-FMXWXDR4.js} +1 -1
- package/out/chunk-HT2SSA3V.js +1 -0
- package/out/{chunk-2WDZPXVL.js → chunk-IMM274IW.js} +1 -1
- package/out/{chunk-FKBXNUW4.js → chunk-KOGNQDSJ.js} +1 -1
- package/out/chunk-LJ7XRXFU.js +52 -0
- package/out/{chunk-PBZPYIG7.js → chunk-MYGJBYYJ.js} +1 -1
- package/out/chunk-NCN3M5EP.js +3 -0
- package/out/chunk-OGAYMEER.js +232 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +26 -26
- package/out/compare.js +14 -14
- package/out/dialogs.js +31 -31
- package/out/graph.js +1 -1
- package/out/index.html +7 -7
- package/out/scan.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +11 -11
- package/package.json +8 -1
- package/server-managed/agent-merges.ts +1 -1
- package/server-managed/app-agents.ts +4 -2
- package/server-managed/commit-lockfiles.ts +7 -4
- package/server-managed/comparison.ts +13 -2
- package/server-managed/db-neon.ts +13 -3
- package/server-managed/db.ts +2 -1
- package/server-managed/dependency-review-comments.ts +16 -18
- package/server-managed/dependency-reviews.ts +45 -27
- package/server-managed/github-app.ts +3 -42
- package/server-managed/github-requests.ts +127 -0
- package/server-managed/http.ts +433 -129
- package/server-managed/listed-activity.ts +39 -0
- package/server-managed/locked-packages.ts +28 -5
- package/server-managed/npm-kept.ts +18 -4
- package/server-managed/npm-patch.ts +325 -0
- package/server-managed/pull-dependencies.ts +9 -6
- package/server-managed/pull-dependency-reviews.ts +19 -13
- package/server-managed/repository-agents.ts +162 -69
- package/server-managed/repository-dependency-reviews.ts +6 -5
- package/server-managed/repository-pulls.ts +114 -58
- package/server-managed/repository-pulse.ts +5 -3
- package/server-managed/repository-stamp.ts +41 -0
- package/server-managed/reviewed-dependencies.ts +4 -5
- package/server-managed/team-app.ts +3 -1
- package/server-managed/team-reports.ts +29 -10
- package/out/chunk-6LNQTKAV.js +0 -52
- package/out/chunk-6S2U2UYG.js +0 -232
- package/out/chunk-FC65TPUB.js +0 -1
- package/out/chunk-UK7RGJT6.js +0 -3
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A period's pull requests and issues, as the Pulse shows them
|
|
2
|
+
// (repository-pulse.ts repositoryActivity), told of those the repository's
|
|
3
|
+
// tabs keep (repository-pulls.ts readList) rather than searched for.
|
|
4
|
+
import { LISTED, type PulseNarrowing, pulseSince } from './repository-pulse.ts'
|
|
5
|
+
import type { KeptIssue, KeptPull } from './repository-pulls.ts'
|
|
6
|
+
|
|
7
|
+
const DAY_MS = 24 * 60 * 60_000
|
|
8
|
+
const WEEK_MS = 7 * DAY_MS
|
|
9
|
+
|
|
10
|
+
// What repositoryActivity tells, of the pull requests and issues kept
|
|
11
|
+
// (repository-pulls.ts readList: `pulls` and `issues`, those updated since
|
|
12
|
+
// the period before it started all read): each counted by the day (UTC), as
|
|
13
|
+
// a search counts it, its narrowing's `authors` by login whatever its case,
|
|
14
|
+
// an app's bot (`name[bot]`) its own, not a user's of its name, and the
|
|
15
|
+
// latest of each listed, as many
|
|
16
|
+
// as LISTED, those merged or closed the latest of all.
|
|
17
|
+
export function listedActivity(pulls: KeptPull[], issues: KeptIssue[], weeks: number, now: number = Date.now(), narrowing: PulseNarrowing | null = null) {
|
|
18
|
+
const since = pulseSince(weeks, now)
|
|
19
|
+
const back = weeks * WEEK_MS
|
|
20
|
+
const dayOf = (at: number) => Math.floor(at / DAY_MS) * DAY_MS
|
|
21
|
+
const [from, to] = [dayOf(narrowing?.from ?? since), narrowing?.to == null ? Number.POSITIVE_INFINITY : dayOf(narrowing.to) + DAY_MS]
|
|
22
|
+
const [before, beforeEnd] = [dayOf(since - back), dayOf(now - back) + DAY_MS]
|
|
23
|
+
// A login whatever its case, a bot's (`name[bot]`, as REST names it) apart from a user's of its name.
|
|
24
|
+
const loginOf = (login: string, bot = false) => `${login.toLowerCase().replace(/\[bot\]$/u, '')}${bot || /\[bot\]$/iu.test(login) ? '[bot]' : ''}`
|
|
25
|
+
const authors = new Set((narrowing?.authors ?? []).map(login => loginOf(login)))
|
|
26
|
+
const by = (row: KeptPull | KeptIssue) => authors.size === 0 || (row.author !== null && authors.has(loginOf(row.author, row.bot)))
|
|
27
|
+
const told = <T extends KeptPull | KeptIssue>(rows: T[], when: (row: T) => number | null, item: (row: T, at: number) => object) => {
|
|
28
|
+
const timed = rows.flatMap(row => { const at = when(row); return at !== null && by(row) ? [{ row, at }] : [] })
|
|
29
|
+
const within = timed.filter(({ at }) => at >= from && at < to)
|
|
30
|
+
return { count: within.length, previous: timed.filter(({ at }) => at >= before && at < beforeEnd).length,
|
|
31
|
+
items: within.toSorted((a, b) => b.at - a.at).slice(0, LISTED).map(({ row, at }) => item(row, at)) }
|
|
32
|
+
}
|
|
33
|
+
const common = (row: KeptPull | KeptIssue, at: number) => ({ number: row.number, title: row.title, author: row.author, authorId: row.authorId ?? null, at,
|
|
34
|
+
createdAt: row.createdAt })
|
|
35
|
+
const pull = (row: KeptPull, at: number) => ({ ...common(row, at), kind: 'pulls', state: row.state, draft: row.draft })
|
|
36
|
+
const issue = (row: KeptIssue, at: number) => ({ ...common(row, at), kind: 'issues', state: row.state, reason: row.reason })
|
|
37
|
+
return { since, previousSince: since - back, merged: told(pulls, row => row.merged?.at ?? null, pull), opened: told(pulls, row => row.createdAt, pull),
|
|
38
|
+
closed: told(issues, row => row.state === 'closed' ? row.closedAt : null, issue), created: told(issues, row => row.createdAt, issue) }
|
|
39
|
+
}
|
|
@@ -26,9 +26,13 @@ export interface LockedPackage {
|
|
|
26
26
|
integrity?: string
|
|
27
27
|
// Where its reviews ask: the sha1 Yarn 1 writes after a tarball's `#`, in
|
|
28
28
|
// hex (`shasum`); the hash of the patch pnpm applies to it
|
|
29
|
-
// (`patchHash`, its patchedDependencies')
|
|
29
|
+
// (`patchHash`, its patchedDependencies'), and the patch's file, where the
|
|
30
|
+
// lockfile names one (`patchPath`, pnpm 9's and 10's): from the lockfile's
|
|
31
|
+
// directory as read, from the repository's root once that is known
|
|
32
|
+
// (patchedIn); and the lockfile's format.
|
|
30
33
|
shasum?: string
|
|
31
34
|
patchHash?: string
|
|
35
|
+
patchPath?: string
|
|
32
36
|
lockfile?: ReviewedLockfile
|
|
33
37
|
// Where only the importers' devDependencies reach it, as npm's `dev` flag
|
|
34
38
|
// says, and an install of no devDependencies leaves it out: in npm's and
|
|
@@ -43,15 +47,19 @@ type Resolution =
|
|
|
43
47
|
| { type: 'tarball'; tarball: string | undefined; integrity: string | undefined; sha1?: string | undefined; gitHosted?: boolean }
|
|
44
48
|
| { type: 'git'; repo: string }
|
|
45
49
|
| { type: 'directory' }
|
|
46
|
-
interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined; dev?: boolean | undefined; patchHash?: string | undefined
|
|
50
|
+
interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined; dev?: boolean | undefined; patchHash?: string | undefined
|
|
51
|
+
patchPath?: string | undefined }
|
|
47
52
|
|
|
48
|
-
// The packages of a lockfile of `format`, as its reader resolves them
|
|
53
|
+
// The packages of a lockfile of `format`, as its reader resolves them, a
|
|
54
|
+
// patched one of pnpm's with its patch's file where the lockfile names it.
|
|
49
55
|
// Versions go unchecked against ranges, as only what is locked is asked for.
|
|
50
56
|
function packagesOf(format: LockfileFormat, text: string): ReadPackage[] {
|
|
51
57
|
if (format === 'npm') return Object.values(parseNpmLockfile(text, { checkVersions: false }).packages)
|
|
52
58
|
if (format === 'pnpm') {
|
|
53
59
|
const { lockfile, env } = parsePnpmLockfile(text)
|
|
60
|
+
const files = new Map(Object.values(lockfile?.patchedDependencies ?? {}).flatMap(({ hash, path }) => path ? [[hash, path] as const] : []))
|
|
54
61
|
return [...Object.values(env?.packages ?? {}), ...Object.values(lockfile?.packages ?? {})]
|
|
62
|
+
.map(pkg => pkg.patchHash !== undefined && files.has(pkg.patchHash) ? { ...pkg, patchPath: files.get(pkg.patchHash) } : pkg)
|
|
55
63
|
}
|
|
56
64
|
if (format !== 'yarn') throw new TypeError(`lockedPackages: ${format} lockfiles are not read`)
|
|
57
65
|
// Yarn 2 and later write another format, which only the text tells apart.
|
|
@@ -88,12 +96,13 @@ function located({ name, version, resolution }: ReadPackage, review: boolean): L
|
|
|
88
96
|
return integrity ? { ...at, ...shown(url), source: 'mirror', integrity } : { ...at, ...shown(url), source: 'tarball' }
|
|
89
97
|
}
|
|
90
98
|
// As above, and with what its reviews ask (LockedPackage `shasum`,
|
|
91
|
-
// `patchHash`, `lockfile`) where `review`.
|
|
99
|
+
// `patchHash`, `patchPath`, `lockfile`) where `review`.
|
|
92
100
|
function locked(pkg: ReadPackage, format: LockfileFormat, review: boolean): LockedPackage | null {
|
|
93
101
|
const one = located(pkg, review)
|
|
94
102
|
if (!one || !review) return one
|
|
95
103
|
const sha1 = pkg.resolution?.type === 'tarball' ? pkg.resolution.sha1 : undefined
|
|
96
|
-
return { ...one, ...sha1 ? { shasum: sha1 } : {}, ...pkg.patchHash ? { patchHash: pkg.patchHash
|
|
104
|
+
return { ...one, ...sha1 ? { shasum: sha1 } : {}, ...pkg.patchHash ? { patchHash: pkg.patchHash, ...pkg.patchPath ? { patchPath: pkg.patchPath } : {} } : {},
|
|
105
|
+
lockfile: format === 'yarn' ? 'yarn1' : format as ReviewedLockfile }
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
// The packages a lockfile of `format` (npm, pnpm or yarn, as
|
|
@@ -117,6 +126,20 @@ export function lockedPackages(format: LockfileFormat, text: string, { review =
|
|
|
117
126
|
return [...found.values()]
|
|
118
127
|
}
|
|
119
128
|
|
|
129
|
+
// `pkg` as a lockfile at `path` locks it, read with its patch's file from
|
|
130
|
+
// the lockfile's directory (LockedPackage `patchPath`): that file from the
|
|
131
|
+
// repository's root, none where it names one outside it.
|
|
132
|
+
export function patchedIn<T extends LockedPackage>(path: string, pkg: T): T {
|
|
133
|
+
if (pkg.patchPath === undefined) return pkg
|
|
134
|
+
const { patchPath, ...rest } = pkg
|
|
135
|
+
const parts = path.split('/').slice(0, -1)
|
|
136
|
+
for (const part of patchPath.split('/')) {
|
|
137
|
+
if (part === '..') { if (parts.pop() === undefined) return rest as T }
|
|
138
|
+
else if (part !== '.' && part !== '') parts.push(part)
|
|
139
|
+
}
|
|
140
|
+
return parts.length > 0 ? { ...rest, patchPath: parts.join('/') } as T : rest as T
|
|
141
|
+
}
|
|
142
|
+
|
|
120
143
|
// Whether a lockfile at `path` is a test's fixture or example: under a
|
|
121
144
|
// directory of tests (`test`, `tests`, `__tests__` and such), in one of
|
|
122
145
|
// fixtures or examples (`fixture`, `fixtures`, `example`, `examples`) inside
|
|
@@ -153,6 +153,10 @@ export const scanReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'loc
|
|
|
153
153
|
export const REPOSITORY_READ_KEPT_MS = 24 * 60 * 60_000
|
|
154
154
|
export const repositoryReadStore = (db: UpstreamCacheStore, debug = false): KeptStore<object> =>
|
|
155
155
|
keptStore<object>(db, 'repository/reads', REPOSITORY_READ_KEPT_MS, debug)
|
|
156
|
+
// GitHub's answers to GETs that came with an ETag (github-requests.ts
|
|
157
|
+
// taggedRead), by their request's key, for any instance to ask again
|
|
158
|
+
// If-None-Match, kept as long as what was read of a repository is.
|
|
159
|
+
export const githubTagStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'github/tags', REPOSITORY_READ_KEPT_MS, debug)
|
|
156
160
|
// Pages of a repository's history (repository-history.ts historyFrom), by its
|
|
157
161
|
// id and the commit each starts at, which never change: kept a month after
|
|
158
162
|
// they were last read from GitHub.
|
|
@@ -169,6 +173,16 @@ export const commitPullStore = <T>(db: UpstreamCacheStore, debug = false): KeptS
|
|
|
169
173
|
// Pulse and the Agents tab read them (repository-pulls.ts readPulls), by its
|
|
170
174
|
// id: kept as its history's pages are, those updated since read on.
|
|
171
175
|
export const pullListStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/pulls', HISTORY_PAGE_KEPT_MS, debug)
|
|
176
|
+
// A branch's tip, by its repository and commit, as the Agents tab looks it
|
|
177
|
+
// up (repository-agents.ts agentBranches), and the commits the app agents'
|
|
178
|
+
// accounts authored from a head, by it (authoredCommits): kept as the
|
|
179
|
+
// history is, neither changing.
|
|
180
|
+
export const branchTipStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/branch-tips', HISTORY_PAGE_KEPT_MS, debug)
|
|
181
|
+
export const authoredCommitStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/agents-authored', HISTORY_PAGE_KEPT_MS, debug)
|
|
182
|
+
// A repository's issues, the most recently updated first, as the Pulse and
|
|
183
|
+
// the Agents tab read them (repository-pulls.ts readIssues): kept as its
|
|
184
|
+
// pull requests are.
|
|
185
|
+
export const issueListStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/issues-list', HISTORY_PAGE_KEPT_MS, debug)
|
|
172
186
|
// The files each of a repository's commits changes (repository-changes.ts
|
|
173
187
|
// repositoryChanges), by the commit, which never change: kept as its
|
|
174
188
|
// history's pages are.
|
|
@@ -180,10 +194,10 @@ export const historyTreeStore = <T>(db: UpstreamCacheStore, debug = false): Kept
|
|
|
180
194
|
// What the code viewer read of a repository's trees (http.ts
|
|
181
195
|
// repositoryTreesOf), which never changes once a commit or tree names it: the
|
|
182
196
|
// tree at a directory of a commit, by the repository's id, the commit and the
|
|
183
|
-
// directory (`trees`),
|
|
184
|
-
// the repository's id and the tree's (`tree-files`)
|
|
185
|
-
// pages are.
|
|
186
|
-
export const codeTreeStore = <T>(db: UpstreamCacheStore, type: 'trees' | 'tree-files', debug = false): KeptStore<T> =>
|
|
197
|
+
// directory (`trees`), how many files a tree holds and their bytes, by
|
|
198
|
+
// the repository's id and the tree's (`tree-files`), and a directory's
|
|
199
|
+
// listing at a commit, by them (`listings`): kept as its history's pages are.
|
|
200
|
+
export const codeTreeStore = <T>(db: UpstreamCacheStore, type: 'trees' | 'tree-files' | 'listings', debug = false): KeptStore<T> =>
|
|
187
201
|
keptStore<T>(db, `repository/code-${type}`, HISTORY_PAGE_KEPT_MS, debug)
|
|
188
202
|
// The commit the code viewer keeps a repository's directories at in byte
|
|
189
203
|
// storage, its default branch's head as last read, and the repository's
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// A pnpm patch of an npm package version (patchedDependencies), as its
|
|
2
|
+
// dependency review reads it (pull-dependency-reviews.ts): its file, which a
|
|
3
|
+
// lockfile names (locked-packages.ts `patchPath`), read of a repository at a
|
|
4
|
+
// commit, and held to the hash the lockfile names it by, as pnpm hashes it,
|
|
5
|
+
// a sha256 in hex from pnpm 10 and an md5 in base32 before, of its text with
|
|
6
|
+
// its CRLFs read as LFs; and applied to the version's files as pnpm applies
|
|
7
|
+
// it as it installs it (patch-package's applyPatch, which pnpm 9, 10 and 11
|
|
8
|
+
// bundle): each file it deletes, creates, renames or changes, a change's
|
|
9
|
+
// hunks each found in the file as it was, where its header says, else as
|
|
10
|
+
// many as FUZZ lines either side, its lines the same but for the space they
|
|
11
|
+
// end in, and those it keeps kept as the file has them. A patch of a file's
|
|
12
|
+
// bytes (git's binary patch), or of a file outside the package, isn't read,
|
|
13
|
+
// and one that doesn't apply fails as a whole; what a patch changes of a
|
|
14
|
+
// file's mode is left out, as the files it reads have none.
|
|
15
|
+
import { Buffer } from 'node:buffer'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
18
|
+
import { isPatchHash, isPatchPath } from '../common/managed/npm-packages.js'
|
|
19
|
+
import { GithubApiError } from './github-app.ts'
|
|
20
|
+
import { upstreamGithub } from './github-errors.ts'
|
|
21
|
+
import { type Lru, lru } from './lru.ts'
|
|
22
|
+
import { NpmPackageError, type NpmPackageFile, npmFileText } from './npm-packages.ts'
|
|
23
|
+
import type { GithubAsked } from './pull-dependencies.ts'
|
|
24
|
+
|
|
25
|
+
// Where a patch is read: its file in a repository (by its id) at a commit,
|
|
26
|
+
// from the repository's root; and the patch, by its hash besides.
|
|
27
|
+
export interface PatchSource { repoId: number; commit: string; path: string }
|
|
28
|
+
export interface NpmPatch extends PatchSource { hash: string }
|
|
29
|
+
|
|
30
|
+
// The most a patch's file may hold, and how many lines either side of where a
|
|
31
|
+
// hunk says its lines are they are looked for.
|
|
32
|
+
export const MAX_PATCH_BYTES = 8 * 1024 * 1024
|
|
33
|
+
const FUZZ = 20
|
|
34
|
+
|
|
35
|
+
const COMMIT = /^[\da-f]{40}$/u
|
|
36
|
+
|
|
37
|
+
// The patch a request names, by its hash (`hash`), its repository's id
|
|
38
|
+
// (`repoId`, a number or its digits), the commit and its file's path; null
|
|
39
|
+
// where it names none of where it is, and 'invalid' where it names one
|
|
40
|
+
// otherwise.
|
|
41
|
+
export function requestedPatch({ hash, repoId, commit, path }: { hash: unknown; repoId: unknown; commit: unknown; path: unknown }): NpmPatch | null | 'invalid' {
|
|
42
|
+
if ([repoId, commit, path].every(field => field == null)) return null
|
|
43
|
+
const id = typeof repoId === 'string' && /^[1-9]\d{0,15}$/u.test(repoId) ? Number(repoId) : repoId
|
|
44
|
+
if (!isPatchHash(hash) || !Number.isSafeInteger(id) || (id as number) <= 0 || typeof commit !== 'string' || !COMMIT.test(commit)
|
|
45
|
+
|| !isPatchPath(path)) return 'invalid'
|
|
46
|
+
return { hash, repoId: id as number, commit, path }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// RFC 4648's base32 of `bytes`, in lowercase and unpadded, as pnpm 9 spells
|
|
50
|
+
// an md5.
|
|
51
|
+
const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567'
|
|
52
|
+
function base32(bytes: Uint8Array) {
|
|
53
|
+
let bits = 0, spelled = '', value = 0
|
|
54
|
+
for (const byte of bytes) {
|
|
55
|
+
value = ((value << 8) | byte) & 0xfff
|
|
56
|
+
bits += 8
|
|
57
|
+
for (; bits >= 5; bits -= 5) spelled += BASE32[(value >>> (bits - 5)) & 31]
|
|
58
|
+
}
|
|
59
|
+
return bits > 0 ? spelled + BASE32[(value << (5 - bits)) & 31] : spelled
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Whether `text` is the patch `hash` names: of its text with its CRLFs as
|
|
63
|
+
// LFs, as pnpm reads it, or as it is.
|
|
64
|
+
export function isPatchOf(text: string, hash: string): boolean {
|
|
65
|
+
return [...new Set([text.replaceAll('\r\n', '\n'), text])].some(read => hash.length === 64
|
|
66
|
+
? createHash('sha256').update(read).digest('hex') === hash : base32(createHash('md5').update(read).digest()) === hash)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A change a patch makes to a file at `path` of the package: deleting it,
|
|
70
|
+
// creating it, renaming it from another (`from`), and its hunks, as
|
|
71
|
+
// patch-package reads them: where its header says its lines are (`at`, the
|
|
72
|
+
// first's index, as it reads `-0`, the first line), how many it finds
|
|
73
|
+
// (`length`), and its lines, each without its LF, in runs of a kind
|
|
74
|
+
// (`parts`): those it keeps, removes or adds, the last of a side told where
|
|
75
|
+
// the file has no LF after it (`unended`).
|
|
76
|
+
export interface HunkPart { kind: 'context' | 'deletion' | 'insertion'; lines: string[]; unended?: true }
|
|
77
|
+
export interface PatchHunk { at: number; length: number; parts: HunkPart[] }
|
|
78
|
+
export interface PatchedFile { path: string; from?: string; deleted?: true; created?: true; hunks: PatchHunk[] }
|
|
79
|
+
|
|
80
|
+
const unreadable = () => new NpmPackageError(422, 'patch-unreadable')
|
|
81
|
+
|
|
82
|
+
// A path as git writes it, unquoted where quoted (as C writes a string, its
|
|
83
|
+
// bytes past ASCII in octal); null where it is quoted otherwise.
|
|
84
|
+
const ESCAPES: Record<string, number> = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92 }
|
|
85
|
+
function unquoted(path: string): string | null {
|
|
86
|
+
if (!path.startsWith('"')) return path
|
|
87
|
+
const bytes: number[] = []
|
|
88
|
+
for (let at = 1; at < path.length; at++) {
|
|
89
|
+
const char = path[at]!
|
|
90
|
+
if (char === '"') return at === path.length - 1 ? new TextDecoder().decode(Uint8Array.from(bytes)) : null
|
|
91
|
+
if (char !== '\\') { bytes.push(...Buffer.from(char)); continue }
|
|
92
|
+
const next = path[++at] ?? ''
|
|
93
|
+
const octal = /^[0-3][0-7]{2}/u.exec(path.slice(at))?.[0]
|
|
94
|
+
if (octal) { bytes.push(Number.parseInt(octal, 8)); at += 2 }
|
|
95
|
+
else if (next in ESCAPES) bytes.push(ESCAPES[next]!)
|
|
96
|
+
else return null
|
|
97
|
+
}
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A side's path in the package, as a `---` or `+++` line names it after its
|
|
102
|
+
// prefix (`a/` or `b/`), git's tab after one with a space in it left out;
|
|
103
|
+
// null for none (`/dev/null`).
|
|
104
|
+
function sidePath(named: string, prefix: string): string | null {
|
|
105
|
+
const path = unquoted(named.endsWith('\t') ? named.slice(0, -1) : named)
|
|
106
|
+
if (path === '/dev/null') return null
|
|
107
|
+
if (path === null || !path.startsWith(prefix)) throw unreadable()
|
|
108
|
+
return path.slice(prefix.length)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The paths a `diff --git` line names, where they are the same, as git
|
|
112
|
+
// writes them for a file neither renamed nor copied: `a/<path> b/<path>`.
|
|
113
|
+
function gitPath(named: string): string | null {
|
|
114
|
+
if (named.startsWith('"')) {
|
|
115
|
+
const quoted = /^("(?:[^"\\]|\\.)*") ("(?:[^"\\]|\\.)*")$/u.exec(named)
|
|
116
|
+
const [a, b] = quoted ? [unquoted(quoted[1]!), unquoted(quoted[2]!)] : []
|
|
117
|
+
return a?.startsWith('a/') && b?.startsWith('b/') && a.slice(2) === b.slice(2) ? a.slice(2) : null
|
|
118
|
+
}
|
|
119
|
+
const length = (named.length - 5) / 2
|
|
120
|
+
const path = named.slice(2, 2 + length)
|
|
121
|
+
return Number.isInteger(length) && named.startsWith('a/') && named.slice(2 + length) === ` b/${path}` ? path : null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The hunk whose `@@` line is at `at` of `lines`, those before `end` read,
|
|
125
|
+
// and the line after it, as patch-package reads it: a line of its own
|
|
126
|
+
// kind, else of those it keeps where it is empty, or a CR alone.
|
|
127
|
+
function readHunk(lines: string[], at: number, end: number): { hunk: PatchHunk; next: number } {
|
|
128
|
+
const range = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/u.exec(lines[at++]!)
|
|
129
|
+
if (!range) throw unreadable()
|
|
130
|
+
let news = Number(range[4] ?? 1), olds = Number(range[2] ?? 1)
|
|
131
|
+
const hunk: PatchHunk = { at: Math.max(Number(range[1]), 1) - 1, length: olds, parts: [] }
|
|
132
|
+
while (olds > 0 || news > 0) {
|
|
133
|
+
// A line its count reads past those read isn't one.
|
|
134
|
+
if (at >= end) throw unreadable()
|
|
135
|
+
const line = lines[at++]!
|
|
136
|
+
const kind = line === '' || line === '\r' || line[0] === ' ' ? 'context' : line[0] === '-' ? 'deletion' : line[0] === '+' ? 'insertion' : null
|
|
137
|
+
if (!kind) throw unreadable()
|
|
138
|
+
const last = hunk.parts.at(-1)
|
|
139
|
+
const part = last?.kind === kind ? last : hunk.parts[hunk.parts.push({ kind, lines: [] }) - 1]!
|
|
140
|
+
part.lines.push(line.slice(1))
|
|
141
|
+
if (kind !== 'insertion') olds--
|
|
142
|
+
if (kind !== 'deletion') news--
|
|
143
|
+
if (olds < 0 || news < 0) throw unreadable()
|
|
144
|
+
// The line before it the last of its side, with no LF after it.
|
|
145
|
+
if (lines[at]?.startsWith('\\')) {
|
|
146
|
+
if (!lines[at++]!.startsWith('\')) throw unreadable()
|
|
147
|
+
part.unended = true
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { hunk, next: at }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A patch's files as git's diff writes them, as pnpm writes a patch: a file's
|
|
154
|
+
// `diff --git` line, its headers, the paths its sides have, and its hunks;
|
|
155
|
+
// what comes before the first file, or between one file and the next, left
|
|
156
|
+
// out. One whose own lines end in CRLFs, as an editor or git's autocrlf
|
|
157
|
+
// leaves one, is read as patch-package reads it: its headers but their CRs,
|
|
158
|
+
// its hunks' lines with them. Throws for one it can't read, or of bytes.
|
|
159
|
+
export function parsePatch(patch: string): PatchedFile[] {
|
|
160
|
+
const lines = patch.split('\n')
|
|
161
|
+
// The lines read: those but the empty one past the patch's last ending.
|
|
162
|
+
const end = lines.length - (patch.endsWith('\n') ? 1 : 0)
|
|
163
|
+
// A header's line but its CR.
|
|
164
|
+
const headerAt = (at: number) => lines[at]?.replace(/\r$/u, '') ?? ''
|
|
165
|
+
const files: PatchedFile[] = []
|
|
166
|
+
for (let at = 0; at < lines.length;) {
|
|
167
|
+
const head = headerAt(at++)
|
|
168
|
+
if (!head.startsWith('diff --git ')) continue
|
|
169
|
+
let created = false, deleted = false, from: string | null = null, to: string | null = null
|
|
170
|
+
let sides: [string | null, string | null] | null = null
|
|
171
|
+
for (; at < lines.length && !/^(?:diff --git |@@ |--- )/u.test(lines[at]!); at++) {
|
|
172
|
+
const header = headerAt(at)
|
|
173
|
+
if (header === 'GIT binary patch' || header.startsWith('Binary files ')) throw new NpmPackageError(422, 'patch-unsupported')
|
|
174
|
+
if (header.startsWith('copy from ') || header.startsWith('copy to ')) throw new NpmPackageError(422, 'patch-unsupported')
|
|
175
|
+
if (header.startsWith('deleted file mode ')) deleted = true
|
|
176
|
+
else if (header.startsWith('new file mode ')) created = true
|
|
177
|
+
else if (header.startsWith('rename from ')) from = unquoted(header.slice('rename from '.length))
|
|
178
|
+
else if (header.startsWith('rename to ')) to = unquoted(header.slice('rename to '.length))
|
|
179
|
+
}
|
|
180
|
+
if (lines[at]?.startsWith('--- ')) {
|
|
181
|
+
if (!lines[at + 1]?.startsWith('+++ ')) throw unreadable()
|
|
182
|
+
sides = [sidePath(headerAt(at).slice(4), 'a/'), sidePath(headerAt(at + 1).slice(4), 'b/')]
|
|
183
|
+
at += 2
|
|
184
|
+
}
|
|
185
|
+
const named = gitPath(head.slice('diff --git '.length))
|
|
186
|
+
const after = to ?? (sides ? sides[1] : named), before = from ?? (sides ? sides[0] : named)
|
|
187
|
+
if ((from === null) !== (to === null) || (deleted && created)) throw unreadable()
|
|
188
|
+
const path = deleted ? before : after
|
|
189
|
+
if (path == null || (!created && before == null) || (!deleted && after == null) || ![before, after].every(side => side == null || isPatchPath(side))
|
|
190
|
+
|| (from === null && !deleted && !created && before !== after)) throw unreadable()
|
|
191
|
+
const hunks: PatchHunk[] = []
|
|
192
|
+
while (lines[at]?.startsWith('@@ ')) {
|
|
193
|
+
const { hunk, next } = readHunk(lines, at, end)
|
|
194
|
+
hunks.push(hunk)
|
|
195
|
+
at = next
|
|
196
|
+
}
|
|
197
|
+
files.push({ path, ...from !== null && to !== null ? { from: before! } : {}, ...deleted ? { deleted: true as const } : {},
|
|
198
|
+
...created ? { created: true as const } : {}, hunks: deleted ? [] : hunks })
|
|
199
|
+
}
|
|
200
|
+
return files
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const doesNotApply = () => new NpmPackageError(422, 'patch-does-not-apply')
|
|
204
|
+
|
|
205
|
+
// A change a hunk makes to a file's lines (`lines`, split at its LFs): those
|
|
206
|
+
// at `splice` that it removes (`remove`) and adds in their place (`insert`),
|
|
207
|
+
// or an empty last line it adds or removes, its LF after the last.
|
|
208
|
+
type LineChange = { splice: number; remove: number; insert: string[] } | 'push' | 'pop'
|
|
209
|
+
|
|
210
|
+
// The changes `hunk` makes where its lines are at `at` of `lines`, as
|
|
211
|
+
// patch-package works them out (evaluateHunk): those it finds the same but
|
|
212
|
+
// for the space they end in, as it compares them; else null.
|
|
213
|
+
function changesAt(hunk: PatchHunk, lines: string[], at: number): LineChange[] | null {
|
|
214
|
+
if (at < 0 || lines.length - at < hunk.length) return null
|
|
215
|
+
const changes: LineChange[] = []
|
|
216
|
+
for (const part of hunk.parts) {
|
|
217
|
+
if (part.kind === 'insertion') {
|
|
218
|
+
changes.push({ splice: at, remove: 0, insert: part.lines })
|
|
219
|
+
if (part.unended) changes.push('pop')
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
for (const line of part.lines) if (lines[at++]!.trimEnd() !== line.trimEnd()) return null
|
|
223
|
+
if (part.kind === 'deletion') {
|
|
224
|
+
changes.push({ splice: at - part.lines.length, remove: part.lines.length, insert: [] })
|
|
225
|
+
if (part.unended) changes.push('push')
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return changes
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// `text` with `hunks` applied as patch-package applies them (applyPatch):
|
|
232
|
+
// each one's lines found in the text as it was, where its header says, else
|
|
233
|
+
// as near as FUZZ lines either side, the nearer first, and the earlier of
|
|
234
|
+
// two as near; then its changes made in turn, past the lines those before
|
|
235
|
+
// added or removed.
|
|
236
|
+
function applied(text: string, hunks: PatchHunk[]): string {
|
|
237
|
+
const lines = text.split('\n')
|
|
238
|
+
const changes = hunks.flatMap(hunk => {
|
|
239
|
+
for (let offset = 0; Math.abs(offset) <= FUZZ; offset = offset < 0 ? -offset : -offset - 1) {
|
|
240
|
+
const found = changesAt(hunk, lines, hunk.at + offset)
|
|
241
|
+
if (found) return found
|
|
242
|
+
}
|
|
243
|
+
throw doesNotApply()
|
|
244
|
+
})
|
|
245
|
+
let shift = 0
|
|
246
|
+
for (const change of changes) {
|
|
247
|
+
if (change === 'push') lines.push('')
|
|
248
|
+
else if (change === 'pop') lines.pop()
|
|
249
|
+
else {
|
|
250
|
+
lines.splice(change.splice + shift, change.remove, ...change.insert)
|
|
251
|
+
shift += change.insert.length - change.remove
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return lines.join('\n')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// A version's `files` with `patch` applied, in the order of their paths, as
|
|
258
|
+
// its tarball's are read; a file it changes the text of must be text. One it
|
|
259
|
+
// creates holds its first hunk's first lines, in place of any there, as
|
|
260
|
+
// patch-package writes it.
|
|
261
|
+
export function patchedFiles(files: NpmPackageFile[], patch: PatchedFile[]): NpmPackageFile[] {
|
|
262
|
+
const byPath = new Map(files.map(file => [file.path, file.bytes]))
|
|
263
|
+
const encoder = new TextEncoder()
|
|
264
|
+
for (const file of patch) {
|
|
265
|
+
if (file.created) {
|
|
266
|
+
const first = file.hunks[0]?.parts[0]
|
|
267
|
+
byPath.set(file.path, encoder.encode(first ? `${first.lines.join('\n')}${first.unended ? '' : '\n'}` : ''))
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
const source = file.from ?? file.path
|
|
271
|
+
const bytes = byPath.get(source)
|
|
272
|
+
if (!bytes) throw doesNotApply()
|
|
273
|
+
if (file.deleted) { byPath.delete(source); continue }
|
|
274
|
+
let next = bytes
|
|
275
|
+
if (file.hunks.length > 0) {
|
|
276
|
+
const text = npmFileText(bytes)
|
|
277
|
+
if (text === null) throw doesNotApply()
|
|
278
|
+
next = encoder.encode(applied(text, file.hunks))
|
|
279
|
+
}
|
|
280
|
+
if (file.from !== undefined) {
|
|
281
|
+
if (byPath.has(file.path)) throw doesNotApply()
|
|
282
|
+
byPath.delete(source)
|
|
283
|
+
}
|
|
284
|
+
byPath.set(file.path, next)
|
|
285
|
+
}
|
|
286
|
+
return [...byPath].map(([path, bytes]) => ({ path, bytes })).toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// About the bytes a patch read takes: the characters of its paths and of the
|
|
290
|
+
// lines its hunks hold, and the room past them each line takes (LINE_COST)
|
|
291
|
+
// and each file, hunk and part (HUNK_COST), as V8 takes it; so a patch of a
|
|
292
|
+
// few long lines (a minified file's) weighs about its size, and one of many
|
|
293
|
+
// short lines or tiny hunks many times it, as it takes.
|
|
294
|
+
const HUNK_COST = 256, LINE_COST = 40
|
|
295
|
+
const held = (lines: string[]) => lines.reduce((sum, line) => sum + LINE_COST + line.length, HUNK_COST)
|
|
296
|
+
export const patchWeight = (files: PatchedFile[]) => files.reduce((sum, file) => sum + HUNK_COST + file.path.length + (file.from?.length ?? 0)
|
|
297
|
+
+ file.hunks.reduce((bytes, hunk) => bytes + HUNK_COST + hunk.parts.reduce((parts, part) => parts + held(part.lines), 0), 0), 1)
|
|
298
|
+
|
|
299
|
+
// Patches read lately, by their source and hash, as what a commit holds never
|
|
300
|
+
// changes: as many as MAX_KEPT_PATCHES of the bytes they take (patchWeight).
|
|
301
|
+
const MAX_KEPT_PATCHES = 64 * 1024 * 1024
|
|
302
|
+
const patches: Lru<PatchedFile[]> = lru(MAX_KEPT_PATCHES, patchWeight)
|
|
303
|
+
|
|
304
|
+
// The patch `patch` names, read as kept, else of repository `fullName` with
|
|
305
|
+
// `token`, held to its hash and read (parsePatch), and kept; `asked` told
|
|
306
|
+
// where GitHub was. Its file missing there, or other than its hash says, or
|
|
307
|
+
// past MAX_PATCH_BYTES, fails.
|
|
308
|
+
export async function readPatch(fullName: string, token: string, patch: NpmPatch, asked?: GithubAsked): Promise<PatchedFile[]> {
|
|
309
|
+
const key = `${patch.repoId}\0${patch.commit}\0${patch.path}\0${patch.hash}`
|
|
310
|
+
const known = patches.get(key)
|
|
311
|
+
if (known) return known
|
|
312
|
+
if (asked) asked.github = true
|
|
313
|
+
let text: string
|
|
314
|
+
try {
|
|
315
|
+
text = await upstreamGithub(() => createClient({ token, userAgent: 'deepview-triage' }).getRepoFile({ repo: fullName, path: patch.path, ref: patch.commit }))
|
|
316
|
+
} catch (err) {
|
|
317
|
+
if (err instanceof GithubApiError && err.message === 'github-not-found') throw new NpmPackageError(404, 'patch-not-found')
|
|
318
|
+
throw err
|
|
319
|
+
}
|
|
320
|
+
if (Buffer.byteLength(text) > MAX_PATCH_BYTES) throw new NpmPackageError(413, 'patch-too-large')
|
|
321
|
+
if (!isPatchOf(text, patch.hash)) throw new NpmPackageError(409, 'patch-mismatch')
|
|
322
|
+
const files = parsePatch(text)
|
|
323
|
+
patches.set(key, files)
|
|
324
|
+
return files
|
|
325
|
+
}
|
|
@@ -30,7 +30,7 @@ import { createClient } from '@preventive/upstream/github.js'
|
|
|
30
30
|
import { GithubApiError, githubJson } from './github-app.ts'
|
|
31
31
|
import { upstreamGithub } from './github-errors.ts'
|
|
32
32
|
import { compareVersions, isExactVersion } from '@preventive/upstream/semver.js'
|
|
33
|
-
import { type LockedPackage, isTestFixture, lockedPackages } from './locked-packages.ts'
|
|
33
|
+
import { type LockedPackage, isTestFixture, lockedPackages, patchedIn } from './locked-packages.ts'
|
|
34
34
|
import { type Lru, lru } from './lru.ts'
|
|
35
35
|
import { type ChangedFile, commitChanges } from './repository-changes.ts'
|
|
36
36
|
|
|
@@ -50,8 +50,9 @@ export const MAX_LOCKED = 20_000
|
|
|
50
50
|
const READ_PARALLEL = 4
|
|
51
51
|
// The rules what is told was told by, kept with it (http.ts
|
|
52
52
|
// pullDependencyKept), and the lockfiles read for it (lockfileKept): one
|
|
53
|
-
// told or read by others is told or read again
|
|
54
|
-
|
|
53
|
+
// told or read by others is told or read again, as those read before pnpm's
|
|
54
|
+
// patches' files were.
|
|
55
|
+
export const PULL_DEPENDENCIES_READ = 4
|
|
55
56
|
const SHA = /^[a-f\d]{40}$/u
|
|
56
57
|
|
|
57
58
|
type Format = 'npm' | 'pnpm' | 'yarn' | 'bun'
|
|
@@ -277,8 +278,9 @@ export const lockedWith = (pkg: LockedPackage) => `${lockedAs(pkg)}\0${pkg.integ
|
|
|
277
278
|
// package versions after none before has, each once, by name and version,
|
|
278
279
|
// each with the versions of its name before, those past MAX_ADDED counted
|
|
279
280
|
// (`more`), not told but reviewed as they are; and every one after, each
|
|
280
|
-
// integrity it is locked with (`locked`), as many as MAX_LOCKED
|
|
281
|
-
// failure among
|
|
281
|
+
// integrity it is locked with (`locked`), as many as MAX_LOCKED, each patch's
|
|
282
|
+
// file from the repository's root (patchedIn); or the first failure among
|
|
283
|
+
// them.
|
|
282
284
|
function addedIn(paths: string[], before: LockfileRead[], after: LockfileRead[]): DirectoryDependencies {
|
|
283
285
|
const failed = [...before, ...after].find(read => 'error' in read)
|
|
284
286
|
if (failed && 'error' in failed) return { paths, error: failed.error, ...failed.detail ? { detail: failed.detail } : {} }
|
|
@@ -292,7 +294,8 @@ function addedIn(paths: string[], before: LockfileRead[], after: LockfileRead[])
|
|
|
292
294
|
const added = new Map<string, AddedPackage>()
|
|
293
295
|
const locked = new Map<string, LockedPackage>()
|
|
294
296
|
for (const read of after) {
|
|
295
|
-
for (const
|
|
297
|
+
for (const found of 'packages' in read ? read.packages : []) {
|
|
298
|
+
const pkg = patchedIn(paths[0]!, found)
|
|
296
299
|
const key = lockedAs(pkg)
|
|
297
300
|
const held = locked.get(lockedWith(pkg))
|
|
298
301
|
if (!held) locked.set(lockedWith(pkg), { ...pkg })
|
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
// tarball (a mirror's too), or with no integrity can't be; a file of the
|
|
18
18
|
// project's isn't. Its reviews are those of it, of an integrity it is locked
|
|
19
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
|
|
21
|
-
// (`
|
|
22
|
-
//
|
|
20
|
+
// version the pull request adds, one it was; for another, any. A patched
|
|
21
|
+
// one's (`patch`) are of it as patched, by its patch's hash, its patch read
|
|
22
|
+
// of the repository where its lockfile names the patch's file (npm-patch.ts):
|
|
23
|
+
// compared with the version it patches, where that is one it was, they review
|
|
24
|
+
// the patch alone. Locked so at that commit, its patch too, it needs none.
|
|
23
25
|
// Which versions the repository's lockfiles locked at that commit never
|
|
24
26
|
// changes: it is read once, every lockfile there of those read here
|
|
25
27
|
// (pull-dependencies.ts), and kept by the commit (`baselines` in this
|
|
@@ -137,11 +139,14 @@ function stancesOf(pkg: LockedPackage, reviews: ReviewsOf, bases: string[] | 'an
|
|
|
137
139
|
type Reviewer = { login: string; status: PublishedReview['status']; passes?: true; comment?: true }
|
|
138
140
|
|
|
139
141
|
// A version as the client is told it: what its reviews are held to left
|
|
140
|
-
// out, but
|
|
141
|
-
|
|
142
|
+
// out, but its patch, where it is patched (`patch`): its hash, which its
|
|
143
|
+
// reviews are held to, and its file, where its lockfile names one, from the
|
|
144
|
+
// repository's root, whence its reviews read it.
|
|
145
|
+
type Patch = { hash: string; path?: string }
|
|
146
|
+
function shown<T extends LockedPackage>(pkg: T): Omit<T, 'integrity' | 'shasum' | 'patchHash' | 'patchPath' | 'lockfile'> & { patch?: Patch } {
|
|
142
147
|
const copy = { ...pkg }
|
|
143
|
-
for (const field of ['integrity', 'shasum', 'patchHash', 'lockfile'] as const) delete copy[field]
|
|
144
|
-
return { ...copy, ...pkg.patchHash ? {
|
|
148
|
+
for (const field of ['integrity', 'shasum', 'patchHash', 'patchPath', 'lockfile'] as const) delete copy[field]
|
|
149
|
+
return { ...copy, ...pkg.patchHash ? { patch: { hash: pkg.patchHash, ...pkg.patchPath ? { path: pkg.patchPath } : {} } } : {} }
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
// A version as the registered reviewed dependencies are asked of it, by
|
|
@@ -204,11 +209,12 @@ function capped<T extends { toReview: unknown[]; unreviewed: unknown[]; unsuppor
|
|
|
204
209
|
}
|
|
205
210
|
// How many versions of `lists` there are, each once across them, by `key`,
|
|
206
211
|
// as the client tells them once: one wanting reviews by its name and
|
|
207
|
-
// version,
|
|
208
|
-
|
|
212
|
+
// version, and its patch's hash, as each patch of it is reviewed apart; one
|
|
213
|
+
// that can't be reviewed by where it is from too.
|
|
214
|
+
type Shown = Pick<LockedPackage, 'name' | 'version' | 'source' | 'from'> & { patch?: Patch }
|
|
209
215
|
const distinct = (lists: Shown[][], key: (pkg: Shown) => string) => new Set(lists.flat().map(key)).size
|
|
210
|
-
const versionKey = (pkg: Shown) => `${pkg.name}@${pkg.version ?? ''}`
|
|
211
|
-
const sourceKey = (pkg: Shown) => `${pkg.source}\0${
|
|
216
|
+
const versionKey = (pkg: Shown) => `${pkg.name}@${pkg.version ?? ''}\0${pkg.patch?.hash ?? ''}`
|
|
217
|
+
const sourceKey = (pkg: Shown) => `${pkg.source}\0${pkg.name}@${pkg.version ?? ''}\0${pkg.from ?? ''}`
|
|
212
218
|
|
|
213
219
|
// The reviews an open pull request's comments make of `wanting`, the
|
|
214
220
|
// versions still wanting one (pull-comment-reviews.ts): those that count,
|
|
@@ -308,9 +314,9 @@ export async function reviewedDependencies(told: PullDependencies, state: RepoRe
|
|
|
308
314
|
}
|
|
309
315
|
|
|
310
316
|
// The package versions a pull request's lockfiles lock that its reviews here
|
|
311
|
-
// may pass, whose reviews are asked: those that can be reviewed
|
|
317
|
+
// may pass, whose reviews are asked: those that can be reviewed.
|
|
312
318
|
export const reviewedPackages = (told: PullDependencies) => told.lockfiles
|
|
313
|
-
.flatMap(dir => 'error' in dir ? [] : dir.locked.filter(pkg => reviewedAs(pkg) === 'version'
|
|
319
|
+
.flatMap(dir => 'error' in dir ? [] : dir.locked.filter(pkg => reviewedAs(pkg) === 'version').map(pkg => ({ name: pkg.name, version: pkg.version! })))
|
|
314
320
|
|
|
315
321
|
// A repository's baseline as read for a pull request: its versions and the
|
|
316
322
|
// lockfiles there left unread; none where it reviews since no commit; and
|