@preventive/triage 1.0.0-alpha.50 → 1.0.0-alpha.51

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 (51) hide show
  1. package/common/managed/finding-detail.d.ts +2 -1
  2. package/common/managed/finding-detail.js +43 -9
  3. package/common/managed/npm-packages.d.ts +2 -0
  4. package/common/managed/npm-packages.js +18 -1
  5. package/common/managed/routes.js +19 -7
  6. package/common/managed/team-app.d.ts +1 -0
  7. package/common/managed/team-app.js +17 -1
  8. package/out/activity.js +4 -4
  9. package/out/{chunk-75YLHBCM.js → chunk-2K6UN3PC.js} +1 -1
  10. package/out/{chunk-2WDZPXVL.js → chunk-7JBSSRD7.js} +1 -1
  11. package/out/{chunk-4XMP7YVH.js → chunk-FMXWXDR4.js} +1 -1
  12. package/out/chunk-HT2SSA3V.js +1 -0
  13. package/out/chunk-IPAH4LKF.js +232 -0
  14. package/out/{chunk-FKBXNUW4.js → chunk-KOGNQDSJ.js} +1 -1
  15. package/out/{chunk-UK7RGJT6.js → chunk-VDRXHIHN.js} +2 -2
  16. package/out/{chunk-HTBYHSPG.js → chunk-WRAJVD35.js} +1 -1
  17. package/out/client-managed-import.js +1 -1
  18. package/out/client-managed.js +25 -25
  19. package/out/compare.js +14 -14
  20. package/out/dialogs.js +41 -41
  21. package/out/graph.js +1 -1
  22. package/out/index.html +6 -6
  23. package/out/scan.js +2 -2
  24. package/out/view.css +1 -1
  25. package/out/view.js +7 -7
  26. package/package.json +3 -1
  27. package/server-managed/agent-merges.ts +1 -1
  28. package/server-managed/app-agents.ts +4 -2
  29. package/server-managed/commit-lockfiles.ts +7 -4
  30. package/server-managed/comparison.ts +13 -2
  31. package/server-managed/db-neon.ts +13 -3
  32. package/server-managed/db.ts +2 -1
  33. package/server-managed/dependency-review-comments.ts +16 -18
  34. package/server-managed/dependency-reviews.ts +45 -27
  35. package/server-managed/github-app.ts +3 -42
  36. package/server-managed/github-requests.ts +97 -0
  37. package/server-managed/http.ts +249 -75
  38. package/server-managed/listed-activity.ts +39 -0
  39. package/server-managed/locked-packages.ts +28 -5
  40. package/server-managed/npm-kept.ts +14 -4
  41. package/server-managed/npm-patch.ts +325 -0
  42. package/server-managed/pull-dependencies.ts +9 -6
  43. package/server-managed/pull-dependency-reviews.ts +19 -13
  44. package/server-managed/repository-agents.ts +162 -69
  45. package/server-managed/repository-dependency-reviews.ts +6 -5
  46. package/server-managed/repository-pulls.ts +114 -58
  47. package/server-managed/repository-pulse.ts +1 -1
  48. package/server-managed/reviewed-dependencies.ts +4 -5
  49. package/server-managed/team-reports.ts +19 -7
  50. package/out/chunk-6S2U2UYG.js +0 -232
  51. package/out/chunk-FC65TPUB.js +0 -1
@@ -101,12 +101,15 @@
101
101
  // POST /api/admin/teams/{set,remove}-member → admin links/unlinks a user (+perms) | 401/403/404
102
102
  // POST /api/admin/teams/set-dependency-review → admin toggles dependency review publication for a team | 400/401/403/404
103
103
  // POST /api/admin/teams/set-npm-scopes → admin replaces a team's npm scopes | 400/401/403/404
104
- // GET/POST /api/npm/reviews → read private/team reviews; save, publish, revoke or continue a set | 400/401/403/404/409
104
+ // GET/POST /api/npm/reviews → read private/team reviews; save, publish, revoke or continue a set; a patched version's
105
+ // (patchHash) changed with where its patch is (patchRepoId, patchCommit, patchPath) | 400/401/403/404/409/422
105
106
  // POST /api/npm/reviews/discard → author discards an unpublished review and its file rows | 400/401/403/409
106
107
  // GET /api/npm/reviews/latest → latest own review for exact target bytes, across all bases | 400/401/403
107
108
  // GET /api/npm/reviews/team?teamId=&beforeTime=&beforeId= → the reviews published to a team, the latest first, for one who
108
109
  // reads them there | 400/401/404
109
110
  // GET /api/npm/package?name=&version= → a published version's manifest and files | 400/401/404/413/429/502
111
+ // GET /api/npm/package?name=&version=&patchHash=&patchRepoId=&patchCommit=&patchPath= → its files as a pnpm patch of a
112
+ // repository the reader reads all of changes them | 400/401/403/404/409/413/422/429/502
110
113
  // GET /api/npm/versions?name= → its dist-tags, versions, when each was published and its first was, and whether a team lists its scope | 400/401/404/429/502
111
114
  // GET /api/npm/download?name=&version= → its tarball | 400/401/404/413/429/502
112
115
  // GET /api/npm/stats?name= → its downloads over the last year and its GitHub repository's figures | 400/401/404/429/502
@@ -135,8 +138,8 @@ import { mergeLinkGroups } from './link-reports.ts'
135
138
  import { backfillFindingIds, reportEntries } from '@preventive/report'
136
139
  import type { IncomingMessage, ServerResponse } from 'node:http'
137
140
  import { pipeline } from 'node:stream/promises'
138
- import { type KeptStore, REPOSITORY_READ_KEPT_MS, codeHeadStore, codeTreeStore, commitPullStore, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore,
139
- lockfileReadStore,
141
+ import { type KeptStore, REPOSITORY_READ_KEPT_MS, authoredCommitStore, branchTipStore, codeHeadStore, codeTreeStore, commitPullStore, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore,
142
+ issueListStore, lockfileReadStore,
140
143
  npmDeltaStore, npmPublishTimeStore, npmVersionStore, ownKeptPrunes, pullDependencyStore, pullListStore, repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
141
144
  import { plainObject } from './npm-manifest.ts'
142
145
  import { type NpmDelta, keptNpmDeltas, npmDelta } from './npm-delta.ts'
@@ -213,13 +216,14 @@ import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
213
216
  import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
214
217
  import { NpmPackageError, type NpmReader, type NpmVersionDocument, type NpmVersionStore, VERSION_KEPT_MS, canReadPrivateNpm, npmFileRows, npmPublishTime, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions, readTogether } from './npm-packages.ts'
215
218
  import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
219
+ import { type NpmPatch, type PatchedFile, patchedFiles, readPatch, requestedPatch } from './npm-patch.ts'
216
220
  import { type RepositoryCodeKept, loadRepositoryCodeBody, repositoryCodeFits, repositoryCodeKept } from './repository-code.ts'
217
221
  import { type ChangesStore, type CommitChanges, type CommitTree, TREES_READ, changesAsked, changesWithin, directoryFiles, repositoryChanges } from './repository-changes.ts'
218
222
  import { COAUTHORS_READ, coauthorsOf } from './commit-coauthors.ts'
219
223
  import { type AllHeads, type HistoryCommit, type HistoryPages, MAX_HISTORY_PAGE, historyFrom, historyStarts, openPullHeads,
220
224
  repositoryAllHeads, repositoryAllHistory, repositoryHistory, repositoryRefTips } from './repository-history.ts'
221
225
  import { type KnownMerges, type SquashedPull, type Squashes, squashedPulls } from './repository-squashes.ts'
222
- import { AGENTS_READ_VERSION, COMMITS_CURSOR, type CommitPulls, repositoryAgents } from './repository-agents.ts'
226
+ import { AGENTS_READ_VERSION, type AuthoredCommit, type BranchTip, type BranchTips, COMMITS_CURSOR, type CommitPulls, repositoryAgents } from './repository-agents.ts'
223
227
  import { ISSUES_CURSOR, ISSUES_READ_VERSION, repositoryIssues, repositoryItem } from './repository-issues.ts'
224
228
  import { type GithubAsked, type ListedLockfile, type LockfileRead, type LockfileStore, MAX_LOCKED, PULL_DEPENDENCIES_READ, type PullCommits, type PullDependencies,
225
229
  type PullDependencyStore, eachOf, lockfileFormat, pullDependenciesAt } from './pull-dependencies.ts'
@@ -230,10 +234,12 @@ import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDepend
230
234
  import { formattedPull, withFormatted } from './dependency-review-comments.ts'
231
235
  import type { ReviewedDependencies } from './reviewed-dependencies.ts'
232
236
  import { commentReviews } from './pull-comment-reviews.ts'
233
- import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseStats, StatsPending, commitStats, defaultHead, periodStats, pulseSince, repositoryActivity, repositoryLines,
237
+ import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseNarrowing, type PulseStats, StatsPending, commitStats, defaultHead, periodStats, pulseSince,
238
+ repositoryActivity, repositoryLines,
234
239
  repositoryStats, withLines, yearCommits } from './repository-pulse.ts'
235
240
  import { type AgentMerges, agentCommits, agentMerges, directCommits, sameMerges } from './agent-merges.ts'
236
- import { PULLS_READ_VERSION, type PullList, pullsReadTo, readPulls } from './repository-pulls.ts'
241
+ import { listedActivity } from './listed-activity.ts'
242
+ import { ISSUES_LIST_READ_VERSION, type IssueList, type Listed, PULLS_READ_VERSION, type PullList, pullsReadTo, readIssues, readPulls } from './repository-pulls.ts'
237
243
  import { AGENT_MARKS_READ } from './app-agents.ts'
238
244
  import { releaseCommits } from './release-commits.ts'
239
245
  import { computeBundleDiff } from '../common/bundle-compare-diff.js'
@@ -246,8 +252,8 @@ import { NpmRiskError } from '../risk/index.js'
246
252
  import { type PrettyCache, PrettyError, type PrettyRequest, compared, prettyRequest, prettyText } from './pretty-print.ts'
247
253
  import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
248
254
  import { typeOnlyFiles } from './type-only.ts'
249
- import { comparisonBody, emptySide, npmComparedSide } from './comparison.ts'
250
- import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
255
+ import { comparisonBody, emptySide, npmComparedSide, patchedVersionBody } from './comparison.ts'
256
+ import { isNpmPackageName, isNpmPackageSpec, isPatchHash, npmPackageScope } from '../common/managed/npm-packages.js'
251
257
 
252
258
  const SESSION_PATH = '/api/auth/session'
253
259
  const PROFILE_PATH = '/api/auth/profile'
@@ -1525,18 +1531,24 @@ type RepositoryReader = Awaited<ReturnType<ReturnType<typeof createRepositoryBro
1525
1531
  type RepositoryContents = Awaited<ReturnType<typeof readRepositoryContents>>
1526
1532
  type RepositoryRefs = Awaited<ReturnType<RepositoryReader['refs']>> & { defaultContents?: RepositoryContents | null }
1527
1533
 
1528
- async function readRepositoryContents(reader: RepositoryReader, ref: string, path: string, virtualEntries: RepositoryEntry[] | null) {
1534
+ // A directory's listing at a commit (`reader.directory`), which never changes,
1535
+ // as kept by them (repositoryTreesOf `listings`), else read.
1536
+ type DirectoryListing = Awaited<ReturnType<RepositoryReader['directory']>>
1537
+ type ListedAt = (commit: string, path: string, read: () => Promise<DirectoryListing>) => Promise<DirectoryListing>
1538
+
1539
+ async function readRepositoryContents(reader: RepositoryReader, ref: string, path: string, virtualEntries: RepositoryEntry[] | null, listed: ListedAt) {
1529
1540
  const commit = await reader.commit(ref)
1530
- const contents = virtualEntries ? { entries: virtualEntries, limited: false } : await reader.directory(path, commit)
1541
+ const contents = virtualEntries ? { entries: virtualEntries, limited: false } : await listed(commit, path, () => reader.directory(path, commit))
1531
1542
  return { ...contents, path, commit }
1532
1543
  }
1533
1544
 
1534
- async function readRepositoryRefs(db: ManagedDb, reader: RepositoryReader, repo: SelectedRepo, withDefault: boolean, virtualEntries: RepositoryEntry[] | null): Promise<RepositoryRefs> {
1545
+ async function readRepositoryRefs(db: ManagedDb, reader: RepositoryReader, repo: SelectedRepo, withDefault: boolean, virtualEntries: RepositoryEntry[] | null,
1546
+ listed: ListedAt): Promise<RepositoryRefs> {
1535
1547
  // Wait for both, including failures: a deleted cached branch must not prevent
1536
1548
  // a successful live default lookup from recovering the initial directory.
1537
1549
  const [refs, contents] = await Promise.allSettled([
1538
1550
  reader.refs(),
1539
- withDefault && repo.cachedDefaultBranch ? readRepositoryContents(reader, `heads/${repo.cachedDefaultBranch}`, '', virtualEntries) : Promise.resolve(null),
1551
+ withDefault && repo.cachedDefaultBranch ? readRepositoryContents(reader, `heads/${repo.cachedDefaultBranch}`, '', virtualEntries, listed) : Promise.resolve(null),
1540
1552
  ])
1541
1553
  if (refs.status === 'rejected') throw refs.reason
1542
1554
  const defaultBranch = refs.value.defaultBranch || null
@@ -1544,7 +1556,7 @@ async function readRepositoryRefs(db: ManagedDb, reader: RepositoryReader, repo:
1544
1556
  if (changed) await db.cacheRepoDefaultBranch(repo, defaultBranch)
1545
1557
  if (!withDefault) return refs.value
1546
1558
  if (!defaultBranch) return { ...refs.value, defaultContents: null }
1547
- if (changed) return { ...refs.value, defaultContents: await readRepositoryContents(reader, `heads/${defaultBranch}`, '', virtualEntries) }
1559
+ if (changed) return { ...refs.value, defaultContents: await readRepositoryContents(reader, `heads/${defaultBranch}`, '', virtualEntries, listed) }
1548
1560
  if (contents.status === 'rejected') throw contents.reason
1549
1561
  return { ...refs.value, defaultContents: contents.value }
1550
1562
  }
@@ -1613,6 +1625,9 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1613
1625
  if (!asked) return
1614
1626
  const { repoId, ref } = asked
1615
1627
  const withDefault = refs && query.get('withDefault') === 'true'
1628
+ // The commit alone (`listing=none`), as a view reading none of its entries
1629
+ // opens at.
1630
+ const commitOnly = !refs && query.get('listing') === 'none'
1616
1631
  const path = refs ? '' : asked.path
1617
1632
  const authorize = async (handed = false) => {
1618
1633
  const access = await authorizeRepository(res, deps, cookie, repoId, path, { handed })
@@ -1626,8 +1641,10 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1626
1641
  const reader = await browser.reader(access.repo)
1627
1642
  // Microseconds, so overlapping browses practically never share a listing time.
1628
1643
  const observedUs = Math.round((performance.timeOrigin + performance.now()) * 1000)
1629
- const revisions = refs ? await readRepositoryRefs(deps.db, reader, access.repo, withDefault, access.virtualEntries) : null
1630
- const contents = refs ? null : await readRepositoryContents(reader, ref, path, access.virtualEntries)
1644
+ const trees = repositoryTreesOf(deps)
1645
+ const listedAt: ListedAt = (commit, at, read) => trees.listing(`${repoId}\0${LISTING_READ}\0${commit}\0${at}`, read)
1646
+ const revisions = refs ? await readRepositoryRefs(deps.db, reader, access.repo, withDefault, access.virtualEntries, listedAt) : null
1647
+ const contents = refs ? null : commitOnly ? { path, commit: await reader.commit(ref) } : await readRepositoryContents(reader, ref, path, access.virtualEntries, listedAt)
1631
1648
  const { tagCommits, tagsComplete, ...listed } = revisions ?? { tagCommits: [], tagsComplete: false }
1632
1649
  // Every refs read (Create a bundle's, and the code viewer's) refreshes the
1633
1650
  // tags bundle catalogs show, and a tag read keeps the one its revision
@@ -1644,7 +1661,7 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1644
1661
  if (!sameRepository(current.repo, access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1645
1662
  sendJson(res, 200, refs ? { ...listed, tagCommits, ...(withDefault ? {
1646
1663
  defaultContents: scopeRepositoryContents(revisions?.defaultContents ?? null, current.virtualEntries),
1647
- } : {}) } : scopeRepositoryContents(contents, current.virtualEntries))
1664
+ } : {}) } : commitOnly ? contents : scopeRepositoryContents(contents as RepositoryContents, current.virtualEntries))
1648
1665
  } catch (err) {
1649
1666
  if (err instanceof GithubApiError) { sendGithubError(res, err); return }
1650
1667
  throw err
@@ -1669,19 +1686,27 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1669
1686
  // the reader's own access has named the commit, as GitHub would have
1670
1687
  // answered them; held by each instance a while, by the database it has, and
1671
1688
  // kept in that database for every instance (npm-kept.ts codeTreeStore).
1689
+ // And a directory's listing at a commit (readRepositoryContents), by the
1690
+ // repository's id, the rules it is read by (LISTING_READ), the commit and the
1691
+ // directory, fewer held, each the larger.
1672
1692
  const REPOSITORY_TREES_KEPT = 4096
1693
+ const REPOSITORY_LISTINGS_HELD = 256
1694
+ const LISTING_READ = 1
1673
1695
  type TreeFiles = Awaited<ReturnType<typeof repositoryTreeFiles>>
1674
- const repositoryTrees = new WeakMap<ManagedDb, { ids: Lru<string>; files: Lru<TreeFiles> }>()
1696
+ const repositoryTrees = new WeakMap<ManagedDb, { ids: Lru<string>; files: Lru<TreeFiles>; listings: Lru<DirectoryListing> }>()
1675
1697
  const treeId = (value: unknown): value is string => typeof value === 'string' && /^[a-f\d]{40}$/u.test(value)
1676
1698
  const treeFiles = (value: unknown): value is TreeFiles => value === null || (plainObject(value)
1677
1699
  && Number.isSafeInteger(value['files']) && (value['files'] as number) >= 0 && Number.isSafeInteger(value['bytes']) && (value['bytes'] as number) >= 0)
1700
+ const directoryListing = (value: unknown): value is DirectoryListing => plainObject(value) && Array.isArray(value['entries']) && typeof value['limited'] === 'boolean'
1678
1701
  function repositoryTreesOf(deps: ManagedHttpDeps) {
1679
1702
  let held = repositoryTrees.get(deps.db)
1680
- if (!held) repositoryTrees.set(deps.db, held = { ids: lru(REPOSITORY_TREES_KEPT), files: lru(REPOSITORY_TREES_KEPT) })
1681
- const { ids, files } = held
1703
+ if (!held) repositoryTrees.set(deps.db, held = { ids: lru(REPOSITORY_TREES_KEPT), files: lru(REPOSITORY_TREES_KEPT), listings: lru(REPOSITORY_LISTINGS_HELD) })
1704
+ const { ids, files, listings } = held
1682
1705
  return {
1683
1706
  id: (key: string, read: () => Promise<string>) => keptTree(ids, codeTreeStore<string>(deps.db, 'trees', deps.config.debug), key, treeId, read),
1684
1707
  files: (key: string, read: () => Promise<TreeFiles>) => keptTree(files, codeTreeStore<TreeFiles>(deps.db, 'tree-files', deps.config.debug), key, treeFiles, read),
1708
+ listing: (key: string, read: () => Promise<DirectoryListing>) =>
1709
+ keptTree(listings, codeTreeStore<DirectoryListing>(deps.db, 'listings', deps.config.debug), key, directoryListing, read),
1685
1710
  }
1686
1711
  }
1687
1712
  // What `memory` holds by `key`, else what `store` keeps of it as `kept`
@@ -2006,8 +2031,9 @@ const REPOSITORY_READ_FLOOR_MS = 10_000
2006
2031
  // How long a history's answer waits on GitHub looking up the pull requests
2007
2032
  // squashed into its commits (handleRepositoryHistory).
2008
2033
  const SQUASH_WAIT_MS = 1500
2009
- // The repositories' reads being read again, behind a kept answer or as asked
2010
- // for afresh, by key: one at a time, those asking meanwhile answered by it.
2034
+ // The repositories' reads being read, first or again, behind a kept answer or
2035
+ // as asked for afresh, by key: one at a time, those asking meanwhile
2036
+ // answered by it.
2011
2037
  const repositoryRereads = new Map<string, Promise<unknown>>()
2012
2038
  // A read GitHub refused, not failed: its credential or the GitHub App's
2013
2039
  // permission for it withdrawn (Issues, Pull requests, Checks), the
@@ -2023,7 +2049,8 @@ const refusedRead = (err: unknown) => err instanceof GithubApiError
2023
2049
  // deps.waitUntil); `kept` where it was, or where reading it again
2024
2050
  // failed as `keepThrough` lets it. One asked for afresh (`fresh`) is read
2025
2051
  // now, unless it was read REPOSITORY_READ_FLOOR_MS since, what was kept
2026
- // answered where that read fails. One GitHub refuses to read again is no
2052
+ // answered where that read fails. Those asking for one not kept while it is
2053
+ // read, as a tab's first readers do together, are answered by that read. One GitHub refuses to read again is no
2027
2054
  // longer kept: the next to ask is refused as GitHub refused it. `at` is when
2028
2055
  // what it answers was read.
2029
2056
  async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string, read: () => Promise<T>,
@@ -2065,7 +2092,7 @@ async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string,
2065
2092
  return { value: held.value as T, kept: true, at: held.at }
2066
2093
  }
2067
2094
  try {
2068
- return { ...now ? await reread() : await readKept(), kept: false }
2095
+ return { ...await reread(), kept: false }
2069
2096
  } catch (err) {
2070
2097
  if (held && ((now && !refusedRead(err)) || keepThrough(err))) return { value: held.value as T, kept: true, at: held.at }
2071
2098
  throw err
@@ -2117,37 +2144,102 @@ function commitPulls(deps: ManagedHttpDeps, repoId: number): CommitPulls {
2117
2144
  }
2118
2145
  }
2119
2146
 
2120
- // How long repository `repoId`'s pull requests kept (keptPulls) are taken as
2121
- // they are before those updated since are read: less than its readers keep
2122
- // their answers (keptAnswer), that one read of them serves both.
2123
- const PULLS_FRESH_MS = 30_000
2124
- // Each repository's reads of its pull requests, by its id: one at a time,
2125
- // each after those asked before it.
2126
- const pullsReads = new Map<number, Promise<unknown>>()
2147
+ // Repository `repoId`'s branches' tips as the Agents tab looks them up
2148
+ // (repository-agents.ts BranchTips), kept by the commit and the rules their
2149
+ // messages' marks were read by (app-agents.ts AGENT_MARKS_READ).
2150
+ function branchTips(deps: ManagedHttpDeps, repoId: number): BranchTips {
2151
+ const store = branchTipStore<BranchTip>(deps.db, deps.config.debug)
2152
+ const keyOf = (sha: string) => `${repoId}\0${AGENT_MARKS_READ}\0${sha}`
2153
+ return {
2154
+ async read(shas) {
2155
+ const kept = await store.readMany(shas.map(keyOf))
2156
+ return new Map(shas.flatMap(sha => { const tip = kept.get(keyOf(sha))?.value; return tip ? [[sha, tip] as const] : [] }))
2157
+ },
2158
+ write: (sha, tip) => store.write(keyOf(sha), tip, Date.now()),
2159
+ }
2160
+ }
2127
2161
 
2128
- // Repository `repoId`'s pull requests, as kept (repository-pulls.ts
2129
- // readPulls), read on where they were read PULLS_FRESH_MS ago or longer, or
2130
- // not as far back as `need`: as far back as it, for the Pulse; for the
2131
- // Agents tab (null), as far back as they were read, those updated since read
2132
- // (a read not finished, on), else their first page. One read of a
2162
+ // The commits repository `repoId`'s app agents' accounts authored from a
2163
+ // head, as the Agents tab reads them (repository-agents.ts AuthoredCommits),
2164
+ // kept by it and the rules their messages' marks were read by
2165
+ // (AGENT_MARKS_READ).
2166
+ function authoredCommits(deps: ManagedHttpDeps, repoId: number) {
2167
+ const store = authoredCommitStore<AuthoredCommit[]>(deps.db, deps.config.debug)
2168
+ const keyOf = (key: string) => `${repoId}\0${AGENT_MARKS_READ}\0${key}`
2169
+ return {
2170
+ read: async (key: string) => { const kept = (await store.read(keyOf(key)))?.value; return Array.isArray(kept) ? kept : null },
2171
+ write: (key: string, commits: AuthoredCommit[]) => store.write(keyOf(key), commits, Date.now()),
2172
+ }
2173
+ }
2174
+
2175
+ // How long a repository's pull requests or issues kept (keptList) are taken
2176
+ // as they are before those updated since are read: less than their readers
2177
+ // keep their answers (keptAnswer), that one read of them serves them all.
2178
+ const LIST_FRESH_MS = 30_000
2179
+ // Each repository's reads of its pull requests, and of its issues, by them
2180
+ // and its id: one at a time, each after those asked before it.
2181
+ const listReads = new Map<string, Promise<unknown>>()
2182
+
2183
+ // A repository's pull requests or issues, as kept in `store` by `key`
2184
+ // (repository-pulls.ts readList, `read`), read on where they were read
2185
+ // LIST_FRESH_MS ago or longer, or not as far back as `need`: as far back as
2186
+ // it, for the Pulse; for the Agents tab (null), as far back as they were
2187
+ // read, those updated since read (a read not finished, on), else their
2188
+ // first page. Not to be read further back (`further` false): those kept,
2189
+ // none where none are, where they are not read as far as `need`, or a read
2190
+ // of them is not finished, those updated since read alone. One read of a
2133
2191
  // repository's at a time, the rest after it, from what it kept.
2134
- function keptPulls(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, need: number | null): Promise<PullList> {
2135
- const store = pullListStore<PullList>(deps.db, deps.config.debug)
2136
- const key = `${repoId}\0${PULLS_READ_VERSION}\0${AGENTS_READ_VERSION}\0${PULSE_YEAR_VERSION}`
2137
- const reading = (pullsReads.get(repoId) ?? Promise.resolve()).catch(() => {}).then(async () => {
2192
+ function keptList<L extends Listed<unknown>>(store: KeptStore<L>, key: string, read: (since: number, kept: L | null, need: number) => Promise<L>,
2193
+ need: number | null, further = true): Promise<L | null> {
2194
+ const reading = (listReads.get(key) ?? Promise.resolve()).catch(() => {}).then(async () => {
2138
2195
  const kept = await store.read(key)
2139
2196
  const list = kept?.value ?? null
2140
- if (list && Date.now() - kept!.at < PULLS_FRESH_MS && (need === null || pullsReadTo(list) <= need)) return list
2197
+ if (list && Date.now() - kept!.at < LIST_FRESH_MS && (need === null || pullsReadTo(list) <= need)) return list
2198
+ if (!further && need !== null && (!list || list.next || pullsReadTo(list) > need)) return list
2141
2199
  const since = pulseSince(52, Date.now())
2142
- const read = await readPulls(fullName, token, since, list, globalThis.fetch,
2143
- need ?? (list ? list.next ? since : list.reach ?? since : Date.now()))
2144
- await store.write(key, read, Date.now())
2145
- return read
2200
+ const listed = await read(since, list, need ?? (list ? list.next ? since : list.reach ?? since : Date.now()))
2201
+ await store.write(key, listed, Date.now())
2202
+ return listed
2146
2203
  })
2147
- pullsReads.set(repoId, reading)
2148
- void reading.catch(() => {}).finally(() => { if (pullsReads.get(repoId) === reading) pullsReads.delete(repoId) })
2204
+ listReads.set(key, reading)
2205
+ void reading.catch(() => {}).finally(() => { if (listReads.get(key) === reading) listReads.delete(key) })
2149
2206
  return reading
2150
2207
  }
2208
+ // Repository `repoId`'s pull requests (keptList), kept by how agents are
2209
+ // told too; those kept alone, not read further back (`further` false).
2210
+ function keptPulls(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, need: number | null): Promise<PullList>
2211
+ function keptPulls(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, need: number, further: false): Promise<PullList | null>
2212
+ function keptPulls(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, need: number | null, further = true) {
2213
+ return keptList(pullListStore<PullList>(deps.db, deps.config.debug), `${repoId}\0${PULLS_READ_VERSION}\0${AGENTS_READ_VERSION}\0${PULSE_YEAR_VERSION}`,
2214
+ (since, kept, asked) => readPulls(fullName, token, since, kept, globalThis.fetch, asked), need, further)
2215
+ }
2216
+ // Repository `repoId`'s issues (keptList).
2217
+ const keptIssues = (deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, need: number | null) =>
2218
+ keptList(issueListStore<IssueList>(deps.db, deps.config.debug), `${repoId}\0${ISSUES_LIST_READ_VERSION}`,
2219
+ (since, kept, asked) => readIssues(fullName, token, since, kept, globalThis.fetch, asked), need) as Promise<IssueList>
2220
+
2221
+ // What is kept by `key` (keptAnswer), read now where it was read
2222
+ // REPOSITORY_READ_FRESH_MS ago or longer, not behind: for an answer that is
2223
+ // kept as fresh itself, as told of it (the Agents tab's).
2224
+ async function currentAnswer<T extends object>(deps: ManagedHttpDeps, key: string, read: () => Promise<T>) {
2225
+ const stored = await repositoryReadStore(deps.db, deps.config.debug).read(key)
2226
+ return keptAnswer(deps, key, read, undefined, !stored || Date.now() - stored.at >= REPOSITORY_READ_FRESH_MS)
2227
+ }
2228
+
2229
+ // Repository `repoId`'s default branch's head, as its tabs read it: as the
2230
+ // Commits tab's branches last told it (`heads`), where they were read
2231
+ // fresh, else read alone and kept (`pulse-head`), read now where that is no
2232
+ // longer fresh as asked (`current`); whether it was kept (`kept`), GitHub
2233
+ // asked for none of it.
2234
+ async function defaultTip(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, current = false): Promise<{ sha: string | null; kept: boolean }> {
2235
+ const heads = await repositoryReadStore(deps.db, deps.config.debug).read(`${repoId}\0heads`)
2236
+ const all = heads && Date.now() - heads.at < REPOSITORY_READ_FRESH_MS ? heads.value as AllHeads : null
2237
+ const tip = all?.heads.find(head => head.name === all.defaultBranch)?.sha
2238
+ if (typeof tip === 'string' && /^[a-f\d]{40}$/u.test(tip)) return { sha: tip, kept: true }
2239
+ const head = async () => ({ sha: await defaultHead(fullName, token) })
2240
+ const read = await (current ? currentAnswer(deps, `${repoId}\0pulse-head`, head) : keptAnswer(deps, `${repoId}\0pulse-head`, head))
2241
+ return { sha: read.value.sha, kept: read.kept }
2242
+ }
2151
2243
 
2152
2244
  // What the Pulse keeps of repository `repoId`'s pull requests merged, as
2153
2245
  // it tells by them whose its year's commits are (pulseCommits, agentMerges).
@@ -2264,6 +2356,22 @@ async function readWholeRepositoryOf<T>(deps: ManagedHttpDeps, access: WholeAcce
2264
2356
  }
2265
2357
  }
2266
2358
 
2359
+ // The patch `patch` names (npm-patch.ts), read of the whole of its repository
2360
+ // as readWholeRepositoryOf reads it, with its reader's own GitHub access, and
2361
+ // held to its hash: as kept, else with that access checked again after; else
2362
+ // why it was refused. Its failing to read or to apply throws.
2363
+ async function npmPatchOf(deps: ManagedHttpDeps, cookie: string | undefined, patch: NpmPatch): Promise<{ files: PatchedFile[] } | Refusal> {
2364
+ const access = await wholeRepositoryAccess(deps, cookie, patch.repoId)
2365
+ if ('error' in access) return access
2366
+ const read = await readWholeRepositoryOf(deps, access, async (fullName, token) => {
2367
+ const asked: GithubAsked = { github: false }
2368
+ return { value: await readPatch(fullName, token, patch, asked), kept: !asked.github }
2369
+ }, () => wholeRepositoryAccess(deps, cookie, patch.repoId))
2370
+ return 'error' in read ? read : { files: read.value }
2371
+ }
2372
+ // The patch a request's fields name, as npm-patch.ts requestedPatch takes them.
2373
+ const patchFields = (field: (key: string) => unknown) => requestedPatch({ hash: field('patchHash'), repoId: field('patchRepoId'), commit: field('patchCommit'), path: field('patchPath') })
2374
+
2267
2375
  // GET /api/admin/repositories/issues: what the code viewer's Issues and Pull
2268
2376
  // requests tabs show (repository-issues.ts): `kind`'s open or closed
2269
2377
  // (`state`), a page at a time, on from the cursor `after` names. They are
@@ -2298,7 +2406,10 @@ async function handleRepositoryAgents(res: ServerResponse, deps: ManagedHttpDeps
2298
2406
  sendJson(res, 400, { error: 'bad-request' }); return
2299
2407
  }
2300
2408
  await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryAgents(fullName, token, cursors, historyPages(deps, repoId, true, true),
2301
- commitPulls(deps, repoId), () => keptPulls(deps, repoId, fullName, token, null)),
2409
+ commitPulls(deps, repoId), { pulls: () => keptPulls(deps, repoId, fullName, token, null), issues: () => keptIssues(deps, repoId, fullName, token, null),
2410
+ head: async () => (await defaultTip(deps, repoId, fullName, token, true)).sha,
2411
+ heads: async () => (await currentAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(fullName, token))).value,
2412
+ tips: branchTips(deps, repoId), authored: authoredCommits(deps, repoId) }),
2302
2413
  `agents\0${AGENTS_READ_VERSION}\0${cursors.pulls ?? ''}\0${cursors.commits ?? ''}`)
2303
2414
  }
2304
2415
 
@@ -2416,8 +2527,8 @@ async function handleRepositoryBranchDependencies(res: ServerResponse, deps: Man
2416
2527
  // setting as read already (`known`), where it was.
2417
2528
  async function branchDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number,
2418
2529
  known?: RepoDependencyReview | null): Promise<{ value: object; kept: boolean }> {
2419
- const headRead = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2420
- const head = headRead.value.sha
2530
+ const headRead = await defaultTip(deps, repoId, fullName, token)
2531
+ const head = headRead.sha
2421
2532
  if (!head) return { value: { head: null, review: null }, kept: false }
2422
2533
  const asked: GithubAsked = { github: !headRead.kept }
2423
2534
  // Those its review holds to alone read, as many as are read of them.
@@ -2447,7 +2558,7 @@ async function handleRepositoryLockfiles(res: ServerResponse, deps: ManagedHttpD
2447
2558
  const repoId = Number(query.get('repoId'))
2448
2559
  if (!Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
2449
2560
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2450
- const head = (await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))).value.sha
2561
+ const head = (await defaultTip(deps, repoId, fullName, token)).sha
2451
2562
  return { value: { head, lockfiles: head ? (await lockfilesAt(fullName, token, head, lockfileKept(deps, repoId))).map(({ path, format }) => ({ path, format })) : [] }, kept: false }
2452
2563
  })
2453
2564
  }
@@ -2603,18 +2714,19 @@ async function teamOpenPulls(deps: ManagedHttpDeps, repoId: number, fullName: st
2603
2714
  }
2604
2715
 
2605
2716
  // The lines the versions wanting reviews of `answers` change, as their rows
2606
- // show them (npm-delta.js deltaBase), where they are counted already: of
2607
- // public versions alone, those npm answered for in a day (npmVersionStore),
2608
- // their integrities those npmDelta keys them by. `[name, version, base,
2609
- // delta]` each, none counted here.
2717
+ // show them (npm-delta.js deltaBase), a patched one's none, where they are
2718
+ // counted already: of public versions alone, those npm answered for in a day
2719
+ // (npmVersionStore), their integrities those npmDelta keys them by. `[name,
2720
+ // version, base, delta]` each, none counted here.
2610
2721
  async function teamDeltas(deps: ManagedHttpDeps, answers: object[]) {
2611
2722
  const asked = new Map<string, [string, string, string]>()
2612
2723
  for (const answer of answers as { lockfiles?: { added?: unknown; unreviewed?: unknown }[]; review?: { required?: number } }[]) {
2613
2724
  const required = answer.review?.required
2614
2725
  if (!Number.isSafeInteger(required)) continue
2615
2726
  for (const pkg of (answer.lockfiles ?? []).flatMap(dir => [...Array.isArray(dir.added) ? dir.added : [], ...Array.isArray(dir.unreviewed) ? dir.unreviewed : []]) as
2616
- { name?: unknown; version?: unknown; source?: unknown; was?: unknown; reviews?: unknown; rejected?: unknown; baseline?: unknown; reviewed?: unknown; unsupported?: unknown }[]) {
2617
- if (pkg.source !== 'registry' || typeof pkg.name !== 'string' || typeof pkg.version !== 'string' || !isExactVersion(pkg.version)) continue
2727
+ { name?: unknown; version?: unknown; source?: unknown; was?: unknown; reviews?: unknown; rejected?: unknown; baseline?: unknown; reviewed?: unknown; unsupported?: unknown
2728
+ patch?: unknown }[]) {
2729
+ if (pkg.source !== 'registry' || pkg.patch || typeof pkg.name !== 'string' || typeof pkg.version !== 'string' || !isExactVersion(pkg.version)) continue
2618
2730
  if (pkg.unsupported || pkg.baseline || pkg.reviewed || typeof pkg.reviews !== 'number' || (pkg.reviews >= required! && !pkg.rejected)) continue
2619
2731
  const was = Array.isArray(pkg.was) ? pkg.was.find(prior => typeof prior === 'string' && prior !== pkg.version) as string | undefined : undefined
2620
2732
  asked.set(`${pkg.name}\0${pkg.version}\0${was ?? '(empty)'}`, [pkg.name, pkg.version, was ?? '(empty)'])
@@ -2719,7 +2831,7 @@ async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps,
2719
2831
  if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks)) { sendJson(res, 400, { error: 'bad-request' }); return }
2720
2832
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2721
2833
  const [period, year] = await Promise.all([
2722
- keptAnswer(deps, `${repoId}\0pulse\0${weeks}`, () => repositoryActivity(fullName, token, weeks)),
2834
+ keptAnswer(deps, `${repoId}\0pulse\0${weeks}`, () => pulseActivity(deps, repoId, fullName, token, weeks)),
2723
2835
  pulseCommits(deps, repoId, fullName, token, weeks),
2724
2836
  ])
2725
2837
  return { value: { weeks, ...period.value, stats: year.stats && periodStats(year.stats, weeks), reading: year.reading, pulls: year.pulls,
@@ -2761,6 +2873,25 @@ const pulseDay = (value: string | null) => {
2761
2873
  return Number.isSafeInteger(at) ? at : null
2762
2874
  }
2763
2875
 
2876
+ // The pull requests and issues of a period of `weeks` weeks, narrowed as
2877
+ // asked (`narrowing`), as the Pulse shows them: of those kept
2878
+ // (listed-activity.ts listedActivity), where they are read whole as far
2879
+ // back as the period before it starts, within the year they are read for:
2880
+ // the issues read on as far first; the pull requests as the year's commits
2881
+ // read them (pulseCommits), never further for this; else searched for
2882
+ // (repositoryActivity), as while they are read.
2883
+ async function pulseActivity(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, weeks: number, narrowing: PulseNarrowing | null = null) {
2884
+ const now = Date.now()
2885
+ // As far back as the period before it starts, and its narrowing's first day.
2886
+ const need = Math.min(pulseSince(2 * weeks, now), narrowing?.from ?? Number.POSITIVE_INFINITY)
2887
+ if (need >= pulseSince(52, now)) {
2888
+ const [pulls, issues] = await Promise.all([keptPulls(deps, repoId, fullName, token, need, false), keptIssues(deps, repoId, fullName, token, need)])
2889
+ const whole = (list: Listed<unknown> | null): list is Listed<unknown> => list !== null && !list.next && pullsReadTo(list) <= need
2890
+ if (whole(pulls) && whole(issues)) return listedActivity(pulls.rows, issues.rows, weeks, now, narrowing)
2891
+ }
2892
+ return repositoryActivity(fullName, token, weeks, now, globalThis.fetch, narrowing)
2893
+ }
2894
+
2764
2895
  // GET /api/admin/repositories/pulse/items: the Pulse tab's pull requests and
2765
2896
  // issues narrowed to a picked day or week (`from` to `to`, else on from
2766
2897
  // `from`) and the GitHub accounts a picked contributor committed as
@@ -2777,7 +2908,7 @@ async function handleRepositoryPulseItems(res: ServerResponse, deps: ManagedHttp
2777
2908
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2778
2909
  const narrowing = { from, to, authors }
2779
2910
  const items = await keptAnswer(deps, `${repoId}\0pulse-items\0${weeks}\0${from}\0${to ?? ''}\0${narrowing.authors.join(',')}`,
2780
- () => repositoryActivity(fullName, token, weeks, Date.now(), globalThis.fetch, narrowing))
2911
+ () => pulseActivity(deps, repoId, fullName, token, weeks, narrowing))
2781
2912
  return { value: { weeks, ...items.value }, kept: items.kept }
2782
2913
  })
2783
2914
  }
@@ -2789,17 +2920,17 @@ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: str
2789
2920
  // weeks and as many before them, which it is shown beside; the year's,
2790
2921
  // all of it.
2791
2922
  const need = Math.max(first, pulseSince(2 * weeks, now))
2792
- const head = await keptAnswer(deps, `${repoId}\0pulse-head`, async () => ({ sha: await defaultHead(fullName, token) }))
2793
- if (!head.value.sha) return { stats: commitStats([], first), reading: null, pulls: null, pending: false, kept: head.kept }
2923
+ const head = await defaultTip(deps, repoId, fullName, token)
2924
+ if (!head.sha) return { stats: commitStats([], first), reading: null, pulls: null, pending: false, kept: head.kept }
2794
2925
  const store = repositoryReadStore(deps.db, deps.config.debug)
2795
- const key = `${repoId}\0pulse-year\0${PULSE_YEAR_VERSION}\0${head.value.sha}\0${first}`
2926
+ const key = `${repoId}\0pulse-year\0${PULSE_YEAR_VERSION}\0${head.sha}\0${first}`
2796
2927
  const lastKey = `${repoId}\0pulse-year-last`
2797
2928
  const mergesKey = pulseMergesKey(repoId)
2798
2929
  const last = async () => {
2799
2930
  const kept = (await store.read(lastKey))?.value as { first: number; stats: PulseStats } | undefined
2800
2931
  return kept && first - kept.first <= PULSE_LAST_MS ? kept.stats : null
2801
2932
  }
2802
- const sha = head.value.sha
2933
+ const sha = head.sha
2803
2934
  // What tells whose the year's commits are, kept as read: the agents' pull
2804
2935
  // requests merged (agentMerges), as the pull requests kept tell, read as
2805
2936
  // far back as `whole` (keptPulls), and the commits that release the
@@ -4343,8 +4474,11 @@ async function npmReviewPublicationLimit(deps: ManagedHttpDeps, reader: NpmReade
4343
4474
  // Reviews are scoped to the bytes actually displayed, including the base:
4344
4475
  // each change but a revocation loads both versions' files, of tarballs held
4345
4476
  // to their integrity and to their shasum where npm tells one (npm-loads.ts),
4346
- // and the review keeps those shasums. Access to both versions is rechecked
4347
- // before returning or saving annotations.
4477
+ // and the review keeps those shasums. A patched version's (`patchHash`) are
4478
+ // its files as its patch changes them, the patch read where the request says
4479
+ // (`patchRepoId`, `patchCommit`, `patchPath`), of the whole of a repository
4480
+ // its reader reads (npmPatchOf), which the review keeps. Access to both
4481
+ // versions is rechecked before returning or saving annotations.
4348
4482
  async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, params: URLSearchParams): Promise<void> {
4349
4483
  const method = req.method ?? 'GET'
4350
4484
  if (!['GET', 'POST'].includes(method)) { send405(res, 'GET, POST'); return }
@@ -4367,13 +4501,17 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
4367
4501
  const basePackage = field('basePackage') ?? null
4368
4502
  const baseVersion = field('baseVersion') ?? null
4369
4503
  const baseIntegrity = field('baseIntegrity') ?? null
4504
+ const patchHash = field('patchHash') ?? null
4505
+ // Where its patch is, where it names that: its hash alone where only listed.
4506
+ const patch = patchFields(field)
4370
4507
  const validIntegrity = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 1024
4371
4508
  if (field('ecosystem') !== 'npm' || !isNpmPackageName(name) || !isNpmPackageSpec(version) || !validIntegrity(integrity)
4372
4509
  || (basePackage !== null || baseVersion !== null || baseIntegrity !== null)
4373
- && (!isNpmPackageName(basePackage) || !isNpmPackageSpec(baseVersion) || !validIntegrity(baseIntegrity))) {
4510
+ && (!isNpmPackageName(basePackage) || !isNpmPackageSpec(baseVersion) || !validIntegrity(baseIntegrity))
4511
+ || (patchHash !== null && !isPatchHash(patchHash)) || patch === 'invalid') {
4374
4512
  sendJson(res, 400, { error: 'bad-review-context' }); return
4375
4513
  }
4376
- const context: ReviewContext = { ecosystem: 'npm', package: name, version, integrity,
4514
+ const context: ReviewContext = { ecosystem: 'npm', package: name, version, integrity, patchHash: patchHash as string | null,
4377
4515
  basePackage: basePackage as string | null, baseVersion: baseVersion as string | null, baseIntegrity: baseIntegrity as string | null }
4378
4516
  let change: ReviewChange | null = null
4379
4517
  if (mutation) {
@@ -4409,6 +4547,8 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
4409
4547
  change = { action: 'save', reviewId: reviewId as string | null, revision: revision as number | null, entryId: entryId as string | null,
4410
4548
  entry: { file, comment, status: status as ReviewInput['status'] } }
4411
4549
  }
4550
+ // Each change but a revocation reads a patched version's patch.
4551
+ if (patchHash !== null && action !== 'revoke' && !patch) { sendJson(res, 400, { error: 'bad-review-context' }); return }
4412
4552
  }
4413
4553
  const controller = new AbortController()
4414
4554
  const onClose = () => { if (!res.writableEnded) controller.abort() }
@@ -4424,15 +4564,22 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
4424
4564
  || (base && (base.version !== baseVersion || base.dist.integrity !== baseIntegrity))) {
4425
4565
  sendJson(res, 409, { error: 'review-content-changed' }); return
4426
4566
  }
4427
- // Each change but a revocation loads both versions' files; a save or a
4428
- // publication is held to them.
4429
- const loads = change && change.action !== 'revoke' ? [doc, ...base ? [base] : []].map(loadNpmFiles) : []
4567
+ // Each change but a revocation loads both versions' files, and a patched
4568
+ // one's patch; a save or a publication is held to them.
4569
+ const reading = change && change.action !== 'revoke'
4570
+ const patchRead = reading && patch ? await npmPatchOf(deps, cookie, patch) : null
4571
+ if (patchRead && 'error' in patchRead) { sendRefusal(res, patchRead); return }
4572
+ const loads = reading ? [doc, ...base ? [base] : []].map(loadNpmFiles) : []
4430
4573
  let tarballs: ReviewedTarballs | undefined
4431
4574
  try {
4432
4575
  const files = await Promise.all(loads.map(load => load.result))
4433
- if (loads.length > 0) tarballs = { shasum: doc.dist.shasum, baseShasum: base?.dist.shasum ?? null }
4576
+ if (loads.length > 0) {
4577
+ tarballs = { shasum: doc.dist.shasum, baseShasum: base?.dist.shasum ?? null,
4578
+ ...patchRead && patch ? { patch: { repoId: patch.repoId, commit: patch.commit, path: patch.path } } : {} }
4579
+ }
4434
4580
  if (change?.action === 'publish' || change?.action === 'save') {
4435
- const maps = files.map(list => new Map<string, unknown>(npmFileRows(list).map(([path, size, text, digest]) => [path, text ?? { format: 'digest', size, digest }])))
4581
+ const maps = files.map((list, at) => new Map<string, unknown>(npmFileRows(at === 0 && patchRead ? patchedFiles(list, patchRead.files) : list)
4582
+ .map(([path, size, text, digest]) => [path, text ?? { format: 'digest', size, digest }])))
4436
4583
  const after = maps[0]!, before = maps[1] ?? new Map<string, unknown>()
4437
4584
  const diff = computeBundleDiff(before, after, () => '.')
4438
4585
  const changed: { path: string; basePath?: string }[] = [...diff.files.onlyBase, ...diff.files.onlyOther, ...diff.files.changed]
@@ -4551,6 +4698,12 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
4551
4698
  }
4552
4699
  const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
4553
4700
  if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
4701
+ // The patch a patched version's files and comparison are of, where named
4702
+ // (`patchHash`, `patchRepoId`, `patchCommit`, `patchPath`), as Review reads them.
4703
+ const patch = patchFields(key => params.get(key))
4704
+ if (patch === 'invalid' || (params.has('patchHash') && !patch) || (patch && path !== NPM_PACKAGE_PATH && path !== NPM_COMPARISON_PATH)) {
4705
+ sendJson(res, 400, { error: 'bad-patch' }); return
4706
+ }
4554
4707
  const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName], { handed: true })
4555
4708
  if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
4556
4709
  const controller = new AbortController()
@@ -4678,15 +4831,20 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
4678
4831
  // same package), as Compare and Review pair them, with what their
4679
4832
  // Overview reads beside them (comparison.ts): the version shown against
4680
4833
  // the one compared with, or `(empty)`, none, as Review reads a first
4681
- // version. Read of both, each package's access checked again once worked out.
4834
+ // version; as `patch` changes its files, where named, the patch read
4835
+ // first (npmPatchOf). Read of both, each package's access checked again
4836
+ // once worked out.
4682
4837
  if (path === NPM_COMPARISON_PATH) {
4683
4838
  const old = otherSpec === '(empty)' ? null : await otherDoc()
4839
+ const patched = patch ? await npmPatchOf(deps, cookie, patch) : null
4840
+ if (patched && 'error' in patched) { sendRefusal(res, patched); return }
4684
4841
  res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
4685
4842
  const loads = [doc, ...old ? [old] : []].map(loadNpmFiles)
4686
4843
  let body
4687
4844
  try {
4688
4845
  const [files, oldFiles] = await Promise.all(loads.map(load => load.result))
4689
- body = await comparisonBody(old ? npmComparedSide(old.dist.integrity, oldFiles!) : emptySide(), npmComparedSide(doc.dist.integrity, files!))
4846
+ body = await comparisonBody(old ? npmComparedSide(old.dist.integrity, oldFiles!) : emptySide(),
4847
+ npmComparedSide(doc.dist.integrity, patched ? patchedFiles(files!, patched.files) : files!))
4690
4848
  } finally { for (const load of loads) load.release() }
4691
4849
  const current = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
4692
4850
  if (current == null) { body.stream.destroy(); sendJson(res, 401, { error: 'unauthenticated' }); return }
@@ -4744,6 +4902,20 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
4744
4902
  await sendEncoded(req, res, diff, 'application/json')
4745
4903
  return
4746
4904
  }
4905
+ // A patched version's files, as its patch changes them (npm-patch.ts),
4906
+ // its patch read first (npmPatchOf), as Review reads them beside the
4907
+ // version's own; none of what is asked beside those.
4908
+ if (patch) {
4909
+ const patched = await npmPatchOf(deps, cookie, patch)
4910
+ if ('error' in patched) { sendRefusal(res, patched); return }
4911
+ const load = loadNpmFiles(doc)
4912
+ let body
4913
+ try { body = await patchedVersionBody(doc, patch, patchedFiles(await load.result, patched.files)) } finally { load.release() }
4914
+ if (!(await recheck(doc.private))) { body.stream.destroy(); return }
4915
+ res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
4916
+ await sendEncoded(req, res, body, 'application/json')
4917
+ return
4918
+ }
4747
4919
  // The load stays held until this response is written or abandoned, and
4748
4920
  // a reader who stops reading it gives it up after a while. One already
4749
4921
  // gone takes none, as its close has passed.
@@ -5240,10 +5412,12 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
5240
5412
  if (method !== 'GET') { send405(res, 'GET'); return }
5241
5413
  const s = workspaceSession!
5242
5414
  const integrity = url.searchParams.get('integrity'), name = url.searchParams.get('package'), version = url.searchParams.get('version')
5243
- if (!isNpmPackageName(name) || !isNpmPackageSpec(version) || !integrity || integrity.length > 1024) {
5415
+ const patchHash = url.searchParams.get('patchHash')
5416
+ if (!isNpmPackageName(name) || !isNpmPackageSpec(version) || !integrity || integrity.length > 1024
5417
+ || (patchHash !== null && !isPatchHash(patchHash))) {
5244
5418
  sendJson(res, 400, { error: 'bad-review-context' }); return
5245
5419
  }
5246
- const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity })
5420
+ const review = await deps.db.getLatestOwnDependencyReview(s.user.id, { ecosystem: 'npm', package: name, version, integrity, patchHash })
5247
5421
  const visible = await readableNpmReviews(res, deps, cookie, s.user.id, review ? [review] : [], s.user)
5248
5422
  if (visible) sendJson(res, 200, { review: (await withFormatted(deps.reviewedDependencies ?? null, s.user.id, visible))[0] ?? null })
5249
5423
  return