@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.50",
3
+ "version": "1.0.0-alpha.52",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": {
@@ -166,6 +166,7 @@
166
166
  "server-managed/lockfile-scope.ts",
167
167
  "server-managed/npm-manifest.ts",
168
168
  "server-managed/npm-packages.ts",
169
+ "server-managed/npm-patch.ts",
169
170
  "server-managed/package-entry-points.ts",
170
171
  "server-managed/pretty-print.ts",
171
172
  "server-managed/pull-reviews.ts",
@@ -196,6 +197,8 @@
196
197
  "server-managed/repository-issues.ts",
197
198
  "server-managed/repository-pulse.ts",
198
199
  "server-managed/repository-pulls.ts",
200
+ "server-managed/listed-activity.ts",
201
+ "server-managed/repository-stamp.ts",
199
202
  "server-managed/repository-aliases.ts",
200
203
  "server-managed/repository-discovery.ts",
201
204
  "server-managed/repository-policy.ts",
@@ -283,6 +286,10 @@
283
286
  "common/managed/comments.ts",
284
287
  "common/managed/finding-detail.d.ts",
285
288
  "common/managed/finding-detail.js",
289
+ "common/managed/finding-values.d.ts",
290
+ "common/managed/finding-values.js",
291
+ "common/managed/lite-name.d.ts",
292
+ "common/managed/lite-name.js",
286
293
  "common/managed/github-agents.ts",
287
294
  "common/managed/github-logins.ts",
288
295
  "common/managed/npm-packages.d.ts",
@@ -33,7 +33,7 @@ type Rebase = [sha: string, at: number, subjects: string[], more?: number]
33
33
  export function agentMerges(list: PullList, since: number): AgentMerges {
34
34
  const merges = new Map<string, number>()
35
35
  const [rebases, pulls, pullRebases]: [Rebase[], [string, number, number][], Rebase[]] = [[], [], []]
36
- for (const { number, merged } of list.pulls) {
36
+ for (const { number, merged } of list.rows) {
37
37
  if (!merged || merged.at < since) continue
38
38
  const { sha, at, subjects, more = 0 } = merged
39
39
  const rebase: Rebase | null = subjects ? more > 0 ? [sha, at, subjects, more] : [sha, at, subjects] : null
@@ -16,6 +16,8 @@ export const APP_AGENTS: readonly (AppAgent & AppAgentMarks)[] = Object.entries<
16
16
  // and the app agents'.
17
17
  export const AGENT_BRANCH_PREFIXES = ['claude/', 'codex/', ...APP_AGENTS.map(agent => agent.prefix)]
18
18
  // What the marks agents leave on commits are read by, as the history keeps
19
- // them (repository-history.ts commitMarks): bumped as how they are told
20
- // changes, so that the Agents tab reads again the pages kept by older rules.
19
+ // them (repository-history.ts commitMarks), and branches' tips and the
20
+ // commits the app agents' accounts authored (repository-agents.ts): bumped
21
+ // as how they are told changes, so that the Agents tab reads again those
22
+ // kept by older rules.
21
23
  export const AGENT_MARKS_READ = 1
@@ -12,7 +12,7 @@
12
12
  import { createClient } from '@preventive/upstream/github.js'
13
13
  import { upstreamGithub } from './github-errors.ts'
14
14
  import { GithubApiError } from './github-app.ts'
15
- import type { LockedPackage } from './locked-packages.ts'
15
+ import { type LockedPackage, patchedIn } from './locked-packages.ts'
16
16
  import { type Lru, lru } from './lru.ts'
17
17
  import { type GithubAsked, type ListedLockfile, type LockfileStore, eachOf, lockedWith, lockfileFormat, readLockfile } from './pull-dependencies.ts'
18
18
 
@@ -57,16 +57,19 @@ export async function lockedAt(fullName: string, token: string, sha: string, kep
57
57
  }
58
58
 
59
59
  // The versions those of `read`'s lockfiles `held` holds to lock, each once,
60
- // and their paths.
60
+ // each patch's file from the repository's root (patchedIn), the first of
61
+ // them that names one there, and their paths.
61
62
  export function lockedIn(read: CommitLockfiles, held: (path: string) => boolean = () => true) {
62
63
  const packages = new Map<string, LockedPackage>()
63
64
  const paths: string[] = []
64
65
  for (const lockfile of read.lockfiles.filter(({ path }) => held(path))) {
65
66
  paths.push(lockfile.path)
66
67
  for (const pkg of lockfile.packages) {
68
+ const found = patchedIn(lockfile.path, { ...pkg })
67
69
  const kept = packages.get(lockedWith(pkg))
68
- if (!kept) packages.set(lockedWith(pkg), { ...pkg })
69
- else if (kept.dev && !pkg.dev) delete kept.dev
70
+ if (!kept) { packages.set(lockedWith(pkg), found); continue }
71
+ if (kept.dev && !pkg.dev) delete kept.dev
72
+ if (kept.patchPath === undefined && found.patchPath !== undefined) kept.patchPath = found.patchPath
70
73
  }
71
74
  }
72
75
  return { packages: [...packages.values()], paths }
@@ -1,5 +1,6 @@
1
1
  // Compare's comparison of two managed bundles or two versions of an npm
2
- // package (GET /api/bundles/:id/comparison?other=, GET /api/npm/comparison):
2
+ // package, the one shown as a patch changes it where Review asks
3
+ // (GET /api/bundles/:id/comparison?other=, GET /api/npm/comparison):
3
4
  // their files paired, at one path or renamed, with the sizes, resolutions,
4
5
  // dependency versions and line counts its Overview and Code read beside them,
5
6
  // as common/bundle-comparison.js prepareComparison works them out. The viewer
@@ -14,7 +15,8 @@ import { prepareComparison } from '../common/bundle-comparison.js'
14
15
  import type { BundleDetails } from '../common/bundle-metadata.js'
15
16
  import type { OpenedBlob } from './blob-store.ts'
16
17
  import { encodeBrotli } from './brotli.ts'
17
- import { type NpmPackageFile, npmFileRows } from './npm-packages.ts'
18
+ import { type NpmPackageFile, type NpmVersionDocument, npmFileRows } from './npm-packages.ts'
19
+ import type { NpmPatch } from './npm-patch.ts'
18
20
 
19
21
  // A side with no files, as Review compares a package's first version with:
20
22
  // one for each comparison, as working one out keeps its line counts on it.
@@ -39,3 +41,12 @@ export async function comparisonBody(base: BundleDetails, other: BundleDetails,
39
41
  const body = await encodeBrotli(Buffer.from(json))
40
42
  return { size: body.byteLength, stream: Readable.from([body]) }
41
43
  }
44
+
45
+ // A version's `files` as `patch` changes them (npm-patch.ts patchedFiles), as
46
+ // the viewer reads a version's (npm-loads.ts loadNpmPackageBody), with the
47
+ // patch, as sent: `{ name, version, private, integrity, patch, files }`.
48
+ export async function patchedVersionBody(doc: NpmVersionDocument, patch: NpmPatch, files: NpmPackageFile[]): Promise<OpenedBlob> {
49
+ const json = JSON.stringify({ name: doc.name, version: doc.version, private: doc.private, integrity: doc.dist.integrity, patch, files: npmFileRows(files) })
50
+ const body = await encodeBrotli(Buffer.from(json))
51
+ return { size: body.byteLength, stream: Readable.from([body]) }
52
+ }
@@ -1,4 +1,4 @@
1
- import { DEPENDENCY_REVIEW_COLUMNS, DEPENDENCY_REVIEW_SCHEMA } from './dependency-reviews.ts'
1
+ import { DEPENDENCY_REVIEW_COLUMNS, DEPENDENCY_REVIEW_INDEXES, DEPENDENCY_REVIEW_SCHEMA } from './dependency-reviews.ts'
2
2
  import { LINK_REPORT_SCHEMA } from './link-reports.ts'
3
3
  import { REPOSITORY_ALIAS_SCHEMA } from './repository-aliases.ts'
4
4
  import { TEAM_NPM_SCOPE_SCHEMA } from './team-npm-scopes.ts'
@@ -39,7 +39,7 @@ const LOCK = 'SELECT pg_advisory_xact_lock(1937006964, 1835101793)'
39
39
  // Whether every version up to the latest is recorded, in one round trip at
40
40
  // a cold start: a database with no versions table yet (undefined_table) has
41
41
  // none. Outside a transaction, so the failed read aborts nothing.
42
- const SCHEMA_VERSIONS = 32
42
+ const SCHEMA_VERSIONS = 33
43
43
  async function currentSchema(db: PgConnection): Promise<boolean> {
44
44
  try {
45
45
  const { rows } = await db.query(`SELECT count(*) AS n FROM managed_schema_version WHERE version BETWEEN 1 AND ${SCHEMA_VERSIONS}`)
@@ -192,6 +192,16 @@ async function migrateDependencyReviewShasums(db: PgConnection): Promise<void> {
192
192
  await db.query('INSERT INTO managed_schema_version VALUES (32)')
193
193
  }
194
194
 
195
+ // The patch a dependency review is of, and where it was read, none of
196
+ // those written before; each author's one draft of a comparison held to its
197
+ // patch too.
198
+ async function migrateDependencyReviewPatches(db: PgConnection): Promise<void> {
199
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 33')).rows.length > 0) return
200
+ await db.query(`ALTER TABLE managed_dependency_review ${DEPENDENCY_REVIEW_COLUMNS.map(([column, type]) => `ADD COLUMN IF NOT EXISTS ${column} ${type}`).join(', ')}`)
201
+ await db.query(DEPENDENCY_REVIEW_INDEXES)
202
+ await db.query('INSERT INTO managed_schema_version VALUES (33)')
203
+ }
204
+
195
205
  async function initialize(db: PgConnection): Promise<void> {
196
206
  if (await currentSchema(db)) return
197
207
  await db.query('BEGIN')
@@ -266,7 +276,7 @@ async function initialize(db: PgConnection): Promise<void> {
266
276
  await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (11)')
267
277
  }
268
278
  for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions, migrateSessionUploadKeys, migrateViewSessions, migrateGithubCommits, migrateTeamNpmScopes, migrateDependencyReviews, migrateGithubLinks, migrateRepositoryDependencyReviews,
269
- migrateRepositoryDependencyReviewLockfiles, migrateRepoUploadIndexes, migrateDependencyReviewShasums]) await migrate(db)
279
+ migrateRepositoryDependencyReviewLockfiles, migrateRepoUploadIndexes, migrateDependencyReviewShasums, migrateDependencyReviewPatches]) await migrate(db)
270
280
  await db.query('COMMIT')
271
281
  } catch (err) {
272
282
  await db.query('ROLLBACK')
@@ -10,7 +10,7 @@ import { revisionSchema } from './revisions.ts'
10
10
  import { MANAGED_SCHEMA, REPO_UPLOAD_INDEXES, VIEW_SESSION_INDEX } from './db-schema.ts'
11
11
  import { GITHUB_LINK_USER_COLUMNS } from './github-links.ts'
12
12
  import { REPOSITORY_DEPENDENCY_REVIEW_COLUMNS } from './repository-dependency-reviews.ts'
13
- import { DEPENDENCY_REVIEW_COLUMNS } from './dependency-reviews.ts'
13
+ import { DEPENDENCY_REVIEW_COLUMNS, DEPENDENCY_REVIEW_INDEXES } from './dependency-reviews.ts'
14
14
  import { STORAGE_SCHEMA } from './storage-db.ts'
15
15
  import { GITHUB_COMMIT_SCHEMA, GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
16
16
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
@@ -126,6 +126,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
126
126
  ensureColumn(db, 'managed_selected_repo', 'active', 'INTEGER NOT NULL DEFAULT 1')
127
127
  for (const [column, type] of REPOSITORY_DEPENDENCY_REVIEW_COLUMNS) ensureColumn(db, 'managed_selected_repo', column, type)
128
128
  for (const [column, type] of DEPENDENCY_REVIEW_COLUMNS) ensureColumn(db, 'managed_dependency_review', column, type)
129
+ db.exec(DEPENDENCY_REVIEW_INDEXES)
129
130
  ensureColumn(db, 'managed_finding_triage_event', 'report_id', 'TEXT')
130
131
  ensureColumn(db, 'managed_finding_triage_event', 'report', 'TEXT')
131
132
  ensureColumn(db, 'managed_finding_triage_event', 'repo', 'TEXT')
@@ -7,7 +7,7 @@
7
7
  // only caller of the hook); and is told of none where it answers nothing, or
8
8
  // fails, which is logged.
9
9
  import type { DependencyReview, DependencyReviewStore } from './dependency-reviews.ts'
10
- import { type AddedPackage, type PullDependencies, eachOf } from './pull-dependencies.ts'
10
+ import { type PullDependencies, eachOf } from './pull-dependencies.ts'
11
11
  import type { PublishedReview, RepoReviewState } from './repository-dependency-reviews.ts'
12
12
  import type { DependencyReviewEntry, ReviewedDependencies } from './reviewed-dependencies.ts'
13
13
 
@@ -20,13 +20,12 @@ const REVIEWS_AT_ONCE = 200
20
20
 
21
21
  // `review` as the hook is given it: each version with the integrity and
22
22
  // shasum the review keeps of the tarball it read, and the version it is of
23
- // with the patch `locked`, a pull request's lockfile, locks it with, where it
24
- // is one's.
25
- function entryOf(review: DependencyReview, locked?: AddedPackage | undefined): DependencyReviewEntry {
23
+ // with the patch it is of, where it is a patched one's.
24
+ function entryOf(review: DependencyReview): DependencyReviewEntry {
26
25
  return { ecosystem: 'npm', old: review.basePackage === null ? null : { name: review.basePackage, version: review.baseVersion ?? '', integrity: review.baseIntegrity ?? '',
27
26
  ...review.baseShasum ? { shasum: review.baseShasum } : {} },
28
27
  new: { name: review.package, version: review.version, integrity: review.integrity, ...review.shasum ? { shasum: review.shasum } : {},
29
- ...locked?.patchHash ? { patchHash: locked.patchHash } : {} },
28
+ ...review.patchHash ? { patchHash: review.patchHash } : {} },
30
29
  status: review.status!, coverage: review.coverage === 'full' ? 'full' : 'partial', comment: review.comment,
31
30
  files: review.entries.filter(entry => entry.status !== null || entry.comment.trim() !== '').map(entry => ({ path: entry.file, status: entry.status, comment: entry.comment })),
32
31
  reviewer: review.userLogin, publishedAt: review.publishedAt! }
@@ -38,13 +37,13 @@ const ownPublished = (review: DependencyReview, user: string) => review.user ===
38
37
  // What the host formats of `reviews`, of them `user`'s own published ones
39
38
  // alone, and the pull request they are of the versions of, where it is
40
39
  // one's; none where it formats none of them.
41
- async function formatOwn(reviewed: ReviewedDependencies | null, user: string, reviews: { review: DependencyReview; locked?: AddedPackage | undefined }[],
40
+ async function formatOwn(reviewed: ReviewedDependencies | null, user: string, reviews: DependencyReview[],
42
41
  pullRequest?: { repository: string; number: number }): Promise<string | null> {
43
42
  const format = reviewed?.formatDependencyReviews
44
- const own = reviews.filter(({ review }) => ownPublished(review, user))
43
+ const own = reviews.filter(review => ownPublished(review, user))
45
44
  if (!format || own.length === 0) return null
46
45
  try {
47
- const text = await format({ reviews: own.map(({ review, locked }) => entryOf(review, locked)), ...pullRequest ? { pullRequest } : {} })
46
+ const text = await format({ reviews: own.map(entryOf), ...pullRequest ? { pullRequest } : {} })
48
47
  return typeof text === 'string' && text.trim() !== '' && text.length <= MAX_FORMATTED ? text : null
49
48
  } catch (error) {
50
49
  console.warn('reviewed dependencies: failed to format dependency reviews', error instanceof Error ? error.message : error)
@@ -57,16 +56,15 @@ async function formatOwn(reviewed: ReviewedDependencies | null, user: string, re
57
56
  export function withFormatted<T extends DependencyReview>(reviewed: ReviewedDependencies | null, user: string, reviews: T[]): Promise<(T & { formatted?: string })[]> {
58
57
  if (!reviewed?.formatDependencyReviews) return Promise.resolve(reviews)
59
58
  return eachOf(reviews, FORMAT_AT_ONCE, async review => {
60
- const formatted = ownPublished(review, user) ? await formatOwn(reviewed, user, [{ review }]) : null
59
+ const formatted = ownPublished(review, user) ? await formatOwn(reviewed, user, [review]) : null
61
60
  return formatted ? { ...review, formatted } : review
62
61
  })
63
62
  }
64
63
 
65
- // Those of `reviews` `user` published of the versions `told` adds, by their
66
- // ids, with each version as its lockfile locks it: of its version, of an
67
- // integrity it is locked with and of its patch, whole or compared with a
68
- // version it was.
69
- function ownOfPull(told: PullDependencies, reviews: PublishedReview[], user: string): Map<string, AddedPackage> {
64
+ // The ids of those of `reviews` `user` published of the versions `told`
65
+ // adds, as its lockfiles lock each: of its version, of an integrity it is
66
+ // locked with and of its patch, whole or compared with a version it was.
67
+ function ownOfPull(told: PullDependencies, reviews: PublishedReview[], user: string): Set<string> {
70
68
  // Theirs by the version they are of, each version probed once.
71
69
  const theirs = new Map<string, PublishedReview[]>()
72
70
  for (const review of reviews) {
@@ -74,13 +72,13 @@ function ownOfPull(told: PullDependencies, reviews: PublishedReview[], user: str
74
72
  const key = `${review.package}@${review.version}`
75
73
  theirs.set(key, [...theirs.get(key) ?? [], review])
76
74
  }
77
- const mine = new Map<string, AddedPackage>()
75
+ const mine = new Set<string>()
78
76
  if (theirs.size === 0) return mine
79
77
  for (const pkg of told.lockfiles.flatMap(dir => 'added' in dir ? dir.added : [])) {
80
78
  const hashes = new Set((pkg.integrity ?? '').split(/\s+/u).filter(Boolean))
81
79
  for (const review of theirs.get(`${pkg.name}@${pkg.version}`) ?? []) {
82
80
  if (!hashes.has(review.integrity) || (review.patchHash ?? '') !== (pkg.patchHash ?? '')) continue
83
- if (review.basePackage === null || (review.basePackage === pkg.name && (pkg.was ?? []).includes(review.baseVersion ?? ''))) mine.set(review.id!, pkg)
81
+ if (review.basePackage === null || (review.basePackage === pkg.name && (pkg.was ?? []).includes(review.baseVersion ?? ''))) mine.add(review.id!)
84
82
  }
85
83
  }
86
84
  return mine
@@ -94,7 +92,7 @@ export async function formattedPull(reviewed: ReviewedDependencies | null, db: P
94
92
  if (!reviewed?.formatDependencyReviews || !state) return null
95
93
  const mine = ownOfPull(told, state.reviews, user)
96
94
  if (mine.size === 0) return null
97
- const ids = [...mine.keys()], reviews: DependencyReview[] = []
95
+ const ids = [...mine], reviews: DependencyReview[] = []
98
96
  for (let at = 0; at < ids.length; at += REVIEWS_AT_ONCE) reviews.push(...await db.listOwnDependencyReviewsById(user, ids.slice(at, at + REVIEWS_AT_ONCE)))
99
- return formatOwn(reviewed, user, reviews.map(review => ({ review, locked: mine.get(review.id) })), pullRequest)
97
+ return formatOwn(reviewed, user, reviews, pullRequest)
100
98
  }
@@ -1,20 +1,27 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
  import { ManagedMutationError } from './management.ts'
3
+ import type { PatchSource } from './npm-patch.ts'
3
4
  import type { ManagedSql } from './sql.ts'
4
5
  import { REVIEW_VERDICTS, type ReviewSummary, type ReviewVerdict, allowedReviewVerdicts, needsPackageJsonReview, summarizeReview } from '../common/dependency-reviews.js'
5
6
 
7
+ // A review is of a version, as npm's registry has it, by its integrity; of
8
+ // it as pnpm patches it (`patchHash`, the patch's hash as a lockfile names
9
+ // it), where it is patched; and compared with a version (its base), or none.
6
10
  export interface ReviewContext {
7
- ecosystem: 'npm'; package: string; version: string; integrity: string
11
+ ecosystem: 'npm'; package: string; version: string; integrity: string; patchHash: string | null
8
12
  basePackage: string | null; baseVersion: string | null; baseIntegrity: string | null
9
13
  }
10
14
  // The shasums (sha1s, in hex) of the tarballs a review reads, its version's
11
15
  // and its base's, where npm tells them: each held, with its integrity, to the
12
- // tarball its reviewer was shown, as a review is written (http.ts).
13
- export interface ReviewedTarballs { shasum: string | null; baseShasum: string | null }
16
+ // tarball its reviewer was shown, as a review is written (http.ts); and where
17
+ // its patch was read (`patch`, npm-patch.ts), a patched version's.
18
+ export interface ReviewedTarballs { shasum: string | null; baseShasum: string | null; patch?: PatchSource | null }
14
19
  export interface ReviewInput { file: string; comment: string; status: ReviewVerdict | null }
15
20
  export interface ReviewEntry extends ReviewInput { id: string; time: number }
16
21
  export interface ReviewTeam { id: string; name: string }
17
- export interface DependencyReview extends ReviewContext, ReviewedTarballs, ReviewSummary {
22
+ export interface DependencyReview extends ReviewContext, Omit<ReviewedTarballs, 'patch'>, ReviewSummary {
23
+ // Where its patch was read, as it was written last: whence it is read again.
24
+ patchSource: PatchSource | null
18
25
  id: string; user: string | null; userLogin: string; userName: string | null; time: number; revision: number
19
26
  publishedAt: number | null; revokedAt: number | null; entries: ReviewEntry[]; teams: ReviewTeam[]; files: string[] | null
20
27
  nonTextFiles: string[] | null
@@ -27,7 +34,7 @@ export type ReviewChange = { action: 'save'; reviewId: string | null; revision:
27
34
  | { action: 'revoke'; reviewId: string; revision: number }
28
35
  export interface DependencyReviewStore {
29
36
  listDependencyReviews(context: ReviewContext, user: string, allBases?: boolean): Promise<DependencyReview[]>
30
- getLatestOwnDependencyReview(user: string, target: Pick<ReviewContext, 'ecosystem' | 'package' | 'version' | 'integrity'>): Promise<DependencyReview | null>
37
+ getLatestOwnDependencyReview(user: string, target: Pick<ReviewContext, 'ecosystem' | 'package' | 'version' | 'integrity' | 'patchHash'>): Promise<DependencyReview | null>
31
38
  listOwnDependencyReviews(user: string, before: { time: number; id: string } | null): Promise<{ reviews: DependencyReview[]; next: { time: number; id: string } | null }>
32
39
  // `user`'s own reviews of `ids`, none another's.
33
40
  listOwnDependencyReviewsById(user: string, ids: string[]): Promise<DependencyReview[]>
@@ -40,14 +47,17 @@ export interface DependencyReviewStore {
40
47
  listDependencyReviewEnabledTeams(): Promise<string[]>
41
48
  setTeamDependencyReview(sessionId: string, teamId: string, enabled: boolean): Promise<boolean>
42
49
  discardDependencyReview(sessionId: string, reviewId: string, revision: number): Promise<void>
43
- // Written with the shasums of the tarballs read (`tarballs`), which a
44
- // draft keeps as it is begun, saved and published; none where not told.
50
+ // Written with the shasums of the tarballs read (`tarballs`), and where its
51
+ // patch was, which a draft keeps as it is begun, saved and published; none
52
+ // where not told.
45
53
  changeDependencyReview(context: ReviewContext, sessionId: string, change: ReviewChange, now: number, tarballs?: ReviewedTarballs): Promise<DependencyReview>
46
54
  }
47
55
 
48
56
  // An unpublished review belongs only to its author. Publication freezes all
49
57
  // entries together and attaches that snapshot to its selected teams. Continuing
50
58
  // makes a new review; published entries and their timestamps never change.
59
+ // Each author's one draft of a comparison is held so by
60
+ // DEPENDENCY_REVIEW_INDEXES, once its columns are.
51
61
  export const DEPENDENCY_REVIEW_SCHEMA = `
52
62
  CREATE TABLE IF NOT EXISTS managed_dependency_review (
53
63
  id TEXT PRIMARY KEY,
@@ -57,13 +67,10 @@ CREATE TABLE IF NOT EXISTS managed_dependency_review (
57
67
  base_package TEXT NOT NULL, base_version TEXT NOT NULL, base_integrity TEXT NOT NULL,
58
68
  time INTEGER NOT NULL, revision INTEGER NOT NULL, published_at INTEGER, revoked_at INTEGER, files TEXT,
59
69
  status TEXT CHECK (status IN ('accept', 'accept-with-nits', 'request-changes', 'reject')), comment TEXT NOT NULL DEFAULT '',
60
- shasum TEXT NOT NULL DEFAULT '', base_shasum TEXT NOT NULL DEFAULT ''
70
+ shasum TEXT NOT NULL DEFAULT '', base_shasum TEXT NOT NULL DEFAULT '', patch_hash TEXT NOT NULL DEFAULT '', patch_source TEXT
61
71
  ) STRICT;
62
72
  CREATE INDEX IF NOT EXISTS managed_dependency_review_context_idx
63
73
  ON managed_dependency_review(ecosystem, package, version, integrity, base_package, base_version, base_integrity, time);
64
- CREATE UNIQUE INDEX IF NOT EXISTS managed_dependency_review_unpublished_idx
65
- ON managed_dependency_review(user_id, ecosystem, package, version, integrity, base_package, base_version, base_integrity)
66
- WHERE published_at IS NULL;
67
74
  CREATE INDEX IF NOT EXISTS managed_dependency_review_author_idx ON managed_dependency_review(user_id, time, id);
68
75
  CREATE TABLE IF NOT EXISTS managed_dependency_file_review (
69
76
  id TEXT PRIMARY KEY, review_id TEXT NOT NULL REFERENCES managed_dependency_review(id) ON DELETE CASCADE,
@@ -79,18 +86,28 @@ CREATE TABLE IF NOT EXISTS managed_dependency_review_team (
79
86
  CREATE INDEX IF NOT EXISTS managed_dependency_review_team_idx ON managed_dependency_review_team(team_id, review_id);
80
87
  `
81
88
  // The columns added since the table was, for a database that has it already.
82
- export const DEPENDENCY_REVIEW_COLUMNS = [['shasum', "TEXT NOT NULL DEFAULT ''"], ['base_shasum', "TEXT NOT NULL DEFAULT ''"]] as const
83
- const values = (c: ReviewContext) => [c.ecosystem, c.package, c.version, c.integrity, c.basePackage ?? '', c.baseVersion ?? '', c.baseIntegrity ?? '']
84
- const targetWhere = 'ecosystem = ? AND package = ? AND version = ? AND integrity = ?'
89
+ export const DEPENDENCY_REVIEW_COLUMNS = [['shasum', "TEXT NOT NULL DEFAULT ''"], ['base_shasum', "TEXT NOT NULL DEFAULT ''"],
90
+ ['patch_hash', "TEXT NOT NULL DEFAULT ''"], ['patch_source', 'TEXT']] as const
91
+ // Each author's one draft of a comparison, its patch's too, made once those
92
+ // columns are: the index held to it before patches were reviewed gone.
93
+ export const DEPENDENCY_REVIEW_INDEXES = `
94
+ DROP INDEX IF EXISTS managed_dependency_review_unpublished_idx;
95
+ CREATE UNIQUE INDEX IF NOT EXISTS managed_dependency_review_draft_idx
96
+ ON managed_dependency_review(user_id, ecosystem, package, version, integrity, patch_hash, base_package, base_version, base_integrity)
97
+ WHERE published_at IS NULL;`
98
+ const values = (c: ReviewContext) => [c.ecosystem, c.package, c.version, c.integrity, c.patchHash ?? '', c.basePackage ?? '', c.baseVersion ?? '', c.baseIntegrity ?? '']
99
+ const targetWhere = 'ecosystem = ? AND package = ? AND version = ? AND integrity = ? AND patch_hash = ?'
85
100
  const contextWhere = `${targetWhere} AND base_package = ? AND base_version = ? AND base_integrity = ?`
86
101
  // Its author by the login they reviewed as, and by the name the app's user
87
102
  // has now (null once they are none, or have none), as the lists show them.
88
103
  const columns = `id, user_id AS user, user_login AS userLogin, (SELECT n.name FROM managed_user n WHERE n.id = user_id) AS userName, ecosystem, package, version, integrity,
89
- NULLIF(base_package, '') AS basePackage, NULLIF(base_version, '') AS baseVersion, NULLIF(base_integrity, '') AS baseIntegrity,
90
- NULLIF(shasum, '') AS shasum, NULLIF(base_shasum, '') AS baseShasum,
104
+ NULLIF(patch_hash, '') AS patchHash, NULLIF(base_package, '') AS basePackage, NULLIF(base_version, '') AS baseVersion, NULLIF(base_integrity, '') AS baseIntegrity,
105
+ NULLIF(shasum, '') AS shasum, NULLIF(base_shasum, '') AS baseShasum, patch_source AS patchSource,
91
106
  time, revision, published_at AS publishedAt, revoked_at AS revokedAt, files, status, comment`
92
107
  const conflict = () => { throw new ManagedMutationError(409, 'review-conflict') }
93
- const shasums = (tarballs: ReviewedTarballs | undefined) => [tarballs?.shasum ?? '', tarballs?.baseShasum ?? '']
108
+ // What a review keeps of the bytes it read: its tarballs' shasums, and where
109
+ // its patch was.
110
+ const shasums = (tarballs: ReviewedTarballs | undefined) => [tarballs?.shasum ?? '', tarballs?.baseShasum ?? '', tarballs?.patch ? JSON.stringify(tarballs.patch) : null]
94
111
 
95
112
  // Who reviews dependencies in team `t`, user `u` as the query joins them: an
96
113
  // admin, in any team; a member who manages, in theirs; a member who triages,
@@ -136,7 +153,7 @@ async function requireReviewer(db: ManagedSql, user: string) {
136
153
  if ((await teams(db, user)).length === 0) throw new ManagedMutationError(403, 'review-access-required')
137
154
  }
138
155
 
139
- type ReviewRow = Omit<DependencyReview, 'files' | 'entries' | 'teams' | keyof ReviewSummary> & { files: string | null }
156
+ type ReviewRow = Omit<DependencyReview, 'files' | 'entries' | 'teams' | 'patchSource' | keyof ReviewSummary> & { files: string | null; patchSource: string | null }
140
157
  async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Promise<DependencyReview[]> {
141
158
  const reviews = rows as ReviewRow[]
142
159
  if (reviews.length === 0) return []
@@ -162,13 +179,14 @@ async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Pro
162
179
  }
163
180
  // A review as its row, its entries and the teams its reader reads it in
164
181
  // tell it: its files' snapshot, its entries a file's each (fileEntries), and
165
- // what they sum up to.
182
+ // what they sum up to; where its patch was read.
166
183
  function expanded(review: ReviewRow, entries: ReviewEntry[], readIn: ReviewTeam[]): DependencyReview {
167
184
  const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
168
185
  const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
169
186
  const nonTextFiles = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
170
187
  const ownEntries = fileEntries(entries)
171
- return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles), teams: readIn }
188
+ return { ...review, patchSource: review.patchSource ? JSON.parse(review.patchSource) as PatchSource : null, files, nonTextFiles, entries: ownEntries,
189
+ ...summarizeReview(ownEntries, files, nonTextFiles), teams: readIn }
172
190
  }
173
191
  // Rows gathered into one JSON array value (json_group_array): its text on
174
192
  // SQLite, parsed already on Postgres, where none gather as NULL.
@@ -185,7 +203,7 @@ async function publish(db: ManagedSql, current: DependencyReview | undefined, us
185
203
  if (!summarizeReview(entries, change.files, change.nonTextFiles).checkedFiles) throw new ManagedMutationError(400, 'no-reviewed-files')
186
204
  if (!change.final || !allowedReviewVerdicts(entries.filter(entry => !change.nonTextFiles.includes(entry.file))).includes(change.final.status)) throw new ManagedMutationError(400, 'bad-final-verdict')
187
205
  if (needsPackageJsonReview(entries, change.files, change.nonTextFiles)) throw new ManagedMutationError(400, 'package-json-not-reviewed')
188
- await db.prepare(`UPDATE managed_dependency_review SET published_at = ?, files = ?, status = ?, comment = ?, shasum = ?, base_shasum = ?, revision = revision + 1
206
+ await db.prepare(`UPDATE managed_dependency_review SET published_at = ?, files = ?, status = ?, comment = ?, shasum = ?, base_shasum = ?, patch_source = ?, revision = revision + 1
189
207
  WHERE id = ?`).run(now, JSON.stringify({ paths: [...new Set(change.files)].toSorted(), nonTextFiles: [...new Set(change.nonTextFiles)].toSorted() }),
190
208
  change.final.status, change.final.comment, ...shasums(tarballs), current.id)
191
209
  } else {
@@ -211,7 +229,7 @@ async function listContext(db: ManagedSql, context: ReviewContext, user: string,
211
229
  AND (user_id = ? OR (published_at IS NOT NULL AND EXISTS (
212
230
  SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = ?
213
231
  WHERE rt.review_id = r.id AND ${reviewsIn(false)})))
214
- ORDER BY time, id`).all(...values(context).slice(0, allBases ? 4 : 7), user, user)
232
+ ORDER BY time, id`).all(...values(context).slice(0, allBases ? 5 : 8), user, user)
215
233
  return expandReviews(db, rows, user)
216
234
  }
217
235
 
@@ -277,8 +295,8 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
277
295
  listTeamDependencyReviews: (teamId, user, before) => listTeam(db, teamId, user, before),
278
296
  async getLatestOwnDependencyReview(user, target) {
279
297
  const row = await db.prepare(`SELECT ${columns} FROM managed_dependency_review
280
- WHERE user_id = ? AND ecosystem = ? AND package = ? AND version = ? AND integrity = ? ORDER BY time DESC, id DESC LIMIT 1`)
281
- .get(user, target.ecosystem, target.package, target.version, target.integrity)
298
+ WHERE user_id = ? AND ${targetWhere} ORDER BY time DESC, id DESC LIMIT 1`)
299
+ .get(user, target.ecosystem, target.package, target.version, target.integrity, target.patchHash ?? '')
282
300
  return row ? (await expandReviews(db, [row], user))[0]! : null
283
301
  },
284
302
  async discardDependencyReview(sessionId, reviewId, revision) {
@@ -312,8 +330,8 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
312
330
  if (await unpublished()) return conflict()
313
331
  const next = randomUUID()
314
332
  await db.prepare(`INSERT INTO managed_dependency_review
315
- (id, user_id, user_login, ecosystem, package, version, integrity, base_package, base_version, base_integrity, shasum, base_shasum, time, revision)
316
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`).run(next, user.id, user.login, ...values(context), ...shasums(tarballs), now)
333
+ (id, user_id, user_login, ecosystem, package, version, integrity, patch_hash, base_package, base_version, base_integrity, shasum, base_shasum, patch_source, time, revision)
334
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`).run(next, user.id, user.login, ...values(context), ...shasums(tarballs), now)
317
335
  return next
318
336
  }
319
337
  if (change.action === 'start') {
@@ -336,7 +354,7 @@ export function dependencyReviewMethods(db: ManagedSql): DependencyReviewStore {
336
354
  } else {
337
355
  if (current?.publishedAt != null) return conflict()
338
356
  if (change.entry.status === null && !change.entryId) throw new ManagedMutationError(400, 'bad-review')
339
- if (id) await db.prepare('UPDATE managed_dependency_review SET time = ?, shasum = ?, base_shasum = ?, revision = revision + 1 WHERE id = ?').run(now, ...shasums(tarballs), id)
357
+ if (id) await db.prepare('UPDATE managed_dependency_review SET time = ?, shasum = ?, base_shasum = ?, patch_source = ?, revision = revision + 1 WHERE id = ?').run(now, ...shasums(tarballs), id)
340
358
  else { if (change.entryId) return conflict(); id = await create() }
341
359
  await saveFile(db, id, change.entry, change.entryId, now)
342
360
  }
@@ -6,16 +6,13 @@ import { Buffer } from 'node:buffer'
6
6
  import { createHash, createSign } from 'node:crypto'
7
7
  import { createClient } from '@preventive/upstream/github.js'
8
8
  import type { ManagedConfig } from './config.ts'
9
- import { GithubApiError, githubUnreachable, throwGithubResponseError, upstreamGithub } from './github-errors.ts'
10
- import { onTokenRejected, shareGithubRead, tokenRejected } from './github-requests.ts'
11
- import { timed } from './request-timing.ts'
9
+ import { GithubApiError, upstreamGithub } from './github-errors.ts'
10
+ import { USER_AGENT, githubJson, onTokenRejected } from './github-requests.ts'
12
11
 
13
12
  export { GithubApiError } from './github-errors.ts'
14
- export { onTokenRejected } from './github-requests.ts'
13
+ export { githubJson, onTokenRejected } from './github-requests.ts'
15
14
 
16
15
  const GITHUB_API = 'https://api.github.com'
17
- const API_VERSION = '2022-11-28'
18
- const USER_AGENT = 'deepview-triage'
19
16
  const PER_PAGE = 100
20
17
  const INSTALLATION_TOKEN_TTL_MS = 5 * 60_000
21
18
  const INSTALLATION_TOKEN_EXPIRY_MARGIN_MS = 60_000
@@ -69,42 +66,6 @@ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
69
66
  return [...byName.values()].toSorted((a, b) => a.fullName.localeCompare(b.fullName))
70
67
  }
71
68
 
72
- // One GitHub API call returning parsed JSON, optionally Bearer-authed by a
73
- // user token, App JWT, or installation token, `body` sent as JSON (a GraphQL
74
- // query's, given longer to answer). Network / non-2xx / malformed fold into a
75
- // GithubApiError (401 passes through for the caller to handle). Identical GETs
76
- // share an outstanding read through its body. POSTs only opt in for read-only
77
- // GraphQL queries (repository-history.ts), never for mutations or token mints.
78
- export function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown,
79
- readOnly = method === 'GET'): Promise<unknown> {
80
- if (readOnly) return shareGithubRead(fetchImpl, JSON.stringify([url, token, method, body]), () => githubJson(url, token, fetchImpl, method, body, false))
81
- // Each call told in its request's Server-Timing (request-timing.ts).
82
- return timed('gh', async () => {
83
- let res: Response
84
- const started = Date.now()
85
- try {
86
- res = await fetchImpl(url, {
87
- method,
88
- signal: AbortSignal.timeout(body === undefined ? 10_000 : 20_000),
89
- redirect: 'error',
90
- headers: {
91
- ...(token == null ? {} : { authorization: `Bearer ${token}` }), 'accept': 'application/vnd.github+json',
92
- ...(body === undefined ? {} : { 'content-type': 'application/json' }), 'user-agent': USER_AGENT, 'x-github-api-version': API_VERSION,
93
- },
94
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
95
- })
96
- } catch (error) { throw githubUnreachable(error, started) }
97
- if (res.status === 401 && token) tokenRejected(token, fetchImpl)
98
- if (!res.ok) await throwGithubResponseError(res)
99
- // The answer's body is read within the same timeout: one not read whole is
100
- // GitHub not reached, as one not answered is; only one that isn't JSON is
101
- // malformed.
102
- try { return await res.json() } catch (error) {
103
- throw error instanceof SyntaxError ? new GithubApiError(502, 'github-malformed') : githubUnreachable(error, started)
104
- }
105
- })
106
- }
107
-
108
69
  // Validate one repo object into a ConnectedRepo (or null to skip), tagging it
109
70
  // with the installation it was reached through (null for the user-token path). A
110
71
  // missing numeric id or full name → skip (can't key/locate it). Archived repos