@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.
Files changed (61) hide show
  1. package/common/managed/finding-detail.d.ts +2 -1
  2. package/common/managed/finding-detail.js +42 -11
  3. package/common/managed/finding-values.d.ts +2 -0
  4. package/common/managed/finding-values.js +44 -0
  5. package/common/managed/lite-name.d.ts +2 -0
  6. package/common/managed/lite-name.js +20 -0
  7. package/common/managed/npm-packages.d.ts +2 -0
  8. package/common/managed/npm-packages.js +18 -1
  9. package/common/managed/routes.js +19 -7
  10. package/common/managed/team-app.d.ts +1 -0
  11. package/common/managed/team-app.js +17 -1
  12. package/out/activity.js +4 -4
  13. package/out/{chunk-75YLHBCM.js → chunk-2K6UN3PC.js} +1 -1
  14. package/out/{chunk-HTBYHSPG.js → chunk-CHRBUGVN.js} +1 -1
  15. package/out/{chunk-4XMP7YVH.js → chunk-FMXWXDR4.js} +1 -1
  16. package/out/chunk-HT2SSA3V.js +1 -0
  17. package/out/{chunk-2WDZPXVL.js → chunk-IMM274IW.js} +1 -1
  18. package/out/{chunk-FKBXNUW4.js → chunk-KOGNQDSJ.js} +1 -1
  19. package/out/chunk-LJ7XRXFU.js +52 -0
  20. package/out/{chunk-PBZPYIG7.js → chunk-MYGJBYYJ.js} +1 -1
  21. package/out/chunk-NCN3M5EP.js +3 -0
  22. package/out/chunk-OGAYMEER.js +232 -0
  23. package/out/client-managed-import.js +1 -1
  24. package/out/client-managed.js +26 -26
  25. package/out/compare.js +14 -14
  26. package/out/dialogs.js +31 -31
  27. package/out/graph.js +1 -1
  28. package/out/index.html +7 -7
  29. package/out/scan.js +2 -2
  30. package/out/view.css +1 -1
  31. package/out/view.js +11 -11
  32. package/package.json +8 -1
  33. package/server-managed/agent-merges.ts +1 -1
  34. package/server-managed/app-agents.ts +4 -2
  35. package/server-managed/commit-lockfiles.ts +7 -4
  36. package/server-managed/comparison.ts +13 -2
  37. package/server-managed/db-neon.ts +13 -3
  38. package/server-managed/db.ts +2 -1
  39. package/server-managed/dependency-review-comments.ts +16 -18
  40. package/server-managed/dependency-reviews.ts +45 -27
  41. package/server-managed/github-app.ts +3 -42
  42. package/server-managed/github-requests.ts +127 -0
  43. package/server-managed/http.ts +433 -129
  44. package/server-managed/listed-activity.ts +39 -0
  45. package/server-managed/locked-packages.ts +28 -5
  46. package/server-managed/npm-kept.ts +18 -4
  47. package/server-managed/npm-patch.ts +325 -0
  48. package/server-managed/pull-dependencies.ts +9 -6
  49. package/server-managed/pull-dependency-reviews.ts +19 -13
  50. package/server-managed/repository-agents.ts +162 -69
  51. package/server-managed/repository-dependency-reviews.ts +6 -5
  52. package/server-managed/repository-pulls.ts +114 -58
  53. package/server-managed/repository-pulse.ts +5 -3
  54. package/server-managed/repository-stamp.ts +41 -0
  55. package/server-managed/reviewed-dependencies.ts +4 -5
  56. package/server-managed/team-app.ts +3 -1
  57. package/server-managed/team-reports.ts +29 -10
  58. package/out/chunk-6LNQTKAV.js +0 -52
  59. package/out/chunk-6S2U2UYG.js +0 -232
  60. package/out/chunk-FC65TPUB.js +0 -1
  61. 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'); and the lockfile's format.
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 } : {}, lockfile: format === 'yarn' ? 'yarn1' : format as ReviewedLockfile }
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`), and how many files a tree holds and their bytes, by
184
- // the repository's id and the tree's (`tree-files`): kept as its history's
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
- export const PULL_DEPENDENCIES_READ = 3
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; or the first
281
- // failure among them.
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 pkg of 'packages' in read ? read.packages : []) {
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 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.
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 whether it is patched.
141
- function shown<T extends LockedPackage>(pkg: T): Omit<T, 'integrity' | 'shasum' | 'patchHash' | 'lockfile'> & { patched?: true } {
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 ? { patched: true as const } : {} }
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, one that can't be reviewed by where it is from too.
208
- type Shown = Pick<LockedPackage, 'name' | 'version' | 'source' | 'from'>
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${versionKey(pkg)}\0${pkg.from ?? ''}`
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, unpatched.
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' && !pkg.patchHash).map(pkg => ({ name: pkg.name, version: 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