@preventive/triage 1.0.0-alpha.43 → 1.0.0-alpha.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/common/bundle-compare-diff.js +2 -167
  2. package/common/bundle-compare-versions.js +172 -0
  3. package/common/bundle-comparison.d.ts +8 -0
  4. package/common/bundle-comparison.js +62 -0
  5. package/common/bundle-sources.js +20 -1
  6. package/common/default-scan-models.ts +1 -1
  7. package/common/dependency-reviews.d.ts +0 -5
  8. package/common/dependency-reviews.js +0 -17
  9. package/common/managed/agent-tasks.ts +292 -0
  10. package/common/managed/github-agents.ts +92 -0
  11. package/common/managed/github-logins.ts +49 -0
  12. package/common/managed/roles.ts +7 -0
  13. package/common/managed/routes.js +91 -10
  14. package/common/managed/team-app.d.ts +5 -2
  15. package/common/managed/team-app.js +2 -2
  16. package/common/server-info.ts +11 -0
  17. package/common/workspace-app.js +19 -3
  18. package/out/brotli-fallback.js +3 -3
  19. package/out/chunk-2XUEZHIP.js +18 -18
  20. package/out/chunk-2ZDZWLFY.js +1 -0
  21. package/out/chunk-3PS4KWD2.js +10 -0
  22. package/out/chunk-5DHL5NG3.js +2 -0
  23. package/out/chunk-6HNXNIQY.js +52 -0
  24. package/out/chunk-7C7DWL7I.js +1 -0
  25. package/out/chunk-AFLZH7WG.js +1 -0
  26. package/out/chunk-BC2UCLFJ.js +1 -0
  27. package/out/chunk-C4Z6ZBXV.js +6 -6
  28. package/out/chunk-GDPCLSOS.js +2 -0
  29. package/out/chunk-GO7FT4OD.js +1 -0
  30. package/out/chunk-HMAJDVXN.js +1 -0
  31. package/out/chunk-I2MDGSQX.js +3 -0
  32. package/out/chunk-IYPY47MC.js +1 -0
  33. package/out/chunk-IZ7POWI3.js +18 -0
  34. package/out/chunk-JCIIBLKR.js +1 -0
  35. package/out/chunk-NPW52BM2.js +17 -0
  36. package/out/chunk-NRPC74AM.js +1 -1
  37. package/out/chunk-NV7ES4EW.js +1 -0
  38. package/out/chunk-OKRLLE5I.js +17 -0
  39. package/out/chunk-OOJM4CTU.js +1 -1
  40. package/out/chunk-PNHBZGDD.js +1 -1
  41. package/out/chunk-PTGDYLNS.js +36 -0
  42. package/out/chunk-PXK7EU4V.js +1 -0
  43. package/out/chunk-Q6MVYGYV.js +3 -0
  44. package/out/chunk-QB4CBOX2.js +1 -0
  45. package/out/chunk-QRD6GIG5.js +229 -0
  46. package/out/chunk-R3NZFXVR.js +1 -0
  47. package/out/chunk-R5O6FAW4.js +1 -1
  48. package/out/chunk-SXV2T5C4.js +80 -0
  49. package/out/chunk-TUII34K2.js +1 -1
  50. package/out/chunk-VFWMQ3LG.js +2 -0
  51. package/out/chunk-VH4XMZIW.js +2 -0
  52. package/out/chunk-WFYLMF6Z.js +10 -0
  53. package/out/chunk-WI7Z5A2T.js +6 -0
  54. package/out/chunk-WQ74ZBZF.js +7 -0
  55. package/out/chunk-XVBEOGOS.js +3 -0
  56. package/out/client-managed-import.js +3 -3
  57. package/out/client-managed.js +31 -35
  58. package/out/client-sync.js +11 -11
  59. package/out/compare.js +35 -0
  60. package/out/dialogs.js +51 -0
  61. package/out/github-issue-authorized.html +1 -1
  62. package/out/github-issue-authorized.js +1 -1
  63. package/out/graph.js +17 -17
  64. package/out/index.html +31 -31
  65. package/out/prism.js +14 -14
  66. package/out/scan.js +2 -0
  67. package/out/terminal.js +294 -293
  68. package/out/view.css +1 -1
  69. package/out/view.js +16 -107
  70. package/package.json +55 -6
  71. package/risk/actions/findings.js +156 -0
  72. package/risk/actions/resolve.js +219 -0
  73. package/risk/actions/scripts/commands.js +182 -0
  74. package/risk/actions/scripts/grade.js +113 -0
  75. package/risk/actions/scripts/packages.js +123 -0
  76. package/risk/actions/scripts/shell.js +165 -0
  77. package/risk/actions/uses.js +76 -0
  78. package/risk/actions/workflow.js +110 -0
  79. package/risk/files/hosts.js +6 -6
  80. package/risk/files/readability.js +23 -17
  81. package/risk/files/runs-code.js +18 -6
  82. package/risk/files/script.js +7 -3
  83. package/risk/files/stamps.js +8 -7
  84. package/risk/index.d.ts +68 -1
  85. package/risk/index.js +6 -2
  86. package/risk/npm/assess.js +13 -8
  87. package/risk/npm/compare.js +34 -10
  88. package/risk/npm/dependencies.js +11 -0
  89. package/risk/npm/files.js +413 -2
  90. package/risk/npm/tarball.js +56 -73
  91. package/server-managed/agent-merges.ts +198 -0
  92. package/server-managed/app-agents.ts +30 -0
  93. package/server-managed/avatar-store.ts +1 -0
  94. package/server-managed/commit-coauthors.ts +83 -0
  95. package/server-managed/commit-lockfiles.ts +62 -0
  96. package/server-managed/comparison.ts +41 -0
  97. package/server-managed/db-methods.ts +29 -10
  98. package/server-managed/db-neon.ts +30 -2
  99. package/server-managed/db-schema.ts +9 -2
  100. package/server-managed/db.ts +7 -0
  101. package/server-managed/dependabot-access.ts +60 -0
  102. package/server-managed/dependabot-alerts.ts +185 -0
  103. package/server-managed/dependency-review-comments.ts +97 -0
  104. package/server-managed/dependency-reviews.ts +65 -15
  105. package/server-managed/github-accounts.ts +33 -0
  106. package/server-managed/github-actions.ts +71 -0
  107. package/server-managed/github-app.ts +23 -9
  108. package/server-managed/github-errors.ts +2 -0
  109. package/server-managed/github-link-oauth.ts +69 -0
  110. package/server-managed/github-links.ts +204 -0
  111. package/server-managed/github-oauth.ts +5 -2
  112. package/server-managed/github-users.ts +30 -0
  113. package/server-managed/http.ts +1219 -81
  114. package/server-managed/index.ts +6 -2
  115. package/server-managed/locked-packages.ts +48 -16
  116. package/server-managed/lockfile-scope.ts +49 -0
  117. package/server-managed/npm-delta-worker.js +23 -0
  118. package/server-managed/npm-delta.ts +173 -0
  119. package/server-managed/npm-insights.ts +20 -11
  120. package/server-managed/npm-kept.ts +51 -4
  121. package/server-managed/npm-loads.ts +20 -13
  122. package/server-managed/npm-packages.ts +24 -64
  123. package/server-managed/pretty-print.ts +14 -9
  124. package/server-managed/public-workspace.ts +26 -8
  125. package/server-managed/pull-comment-reviews.ts +161 -0
  126. package/server-managed/pull-dependencies.ts +280 -0
  127. package/server-managed/pull-dependency-reviews.ts +318 -0
  128. package/server-managed/pull-reviews.ts +205 -0
  129. package/server-managed/release-commits.ts +84 -0
  130. package/server-managed/repository-actions.ts +209 -0
  131. package/server-managed/repository-agents.ts +220 -0
  132. package/server-managed/repository-browser.ts +54 -13
  133. package/server-managed/repository-changes.ts +284 -0
  134. package/server-managed/repository-code.ts +52 -0
  135. package/server-managed/repository-dependency-reviews.ts +135 -0
  136. package/server-managed/repository-history.ts +445 -0
  137. package/server-managed/repository-issues.ts +181 -0
  138. package/server-managed/repository-pulse.ts +432 -0
  139. package/server-managed/repository-risk-read.ts +165 -0
  140. package/server-managed/repository-risk.ts +116 -106
  141. package/server-managed/reviewed-dependencies.ts +149 -0
  142. package/server-managed/session.ts +9 -5
  143. package/server-managed/sql.ts +1 -1
  144. package/server-managed/storage-stores.ts +5 -0
  145. package/server-managed/team-app.ts +54 -14
  146. package/server-managed/workspace-shares.ts +4 -1
  147. package/server.ts +11 -0
  148. package/out/chunk-2KBF5TZ7.js +0 -2
  149. package/out/chunk-35VE6CDH.js +0 -2
  150. package/out/chunk-3BZ7JERB.js +0 -1
  151. package/out/chunk-4F6JSF57.js +0 -3
  152. package/out/chunk-4XCDFGDP.js +0 -10
  153. package/out/chunk-7RU5EDRO.js +0 -226
  154. package/out/chunk-A7M4GV7M.js +0 -1
  155. package/out/chunk-CLPKNNC7.js +0 -51
  156. package/out/chunk-CMQA4DM6.js +0 -1
  157. package/out/chunk-CVVJAXVY.js +0 -2
  158. package/out/chunk-DIJJNTRH.js +0 -10
  159. package/out/chunk-GJ4IUCNS.js +0 -1
  160. package/out/chunk-GNXOKIGP.js +0 -80
  161. package/out/chunk-GZ4MS3TE.js +0 -13
  162. package/out/chunk-IU3CCINR.js +0 -3
  163. package/out/chunk-K3X5EJBQ.js +0 -2
  164. package/out/chunk-KEX2ATEW.js +0 -2
  165. package/out/chunk-KYCNJVB7.js +0 -1
  166. package/out/chunk-LAAB5SZT.js +0 -2
  167. package/out/chunk-NWZ4PVPN.js +0 -1
  168. package/out/chunk-OSIMGFUF.js +0 -1
  169. package/out/chunk-RFZQQXSG.js +0 -17
  170. package/out/chunk-RSHMVYNS.js +0 -1
  171. package/out/chunk-TZ5KB2Y5.js +0 -10
  172. package/out/chunk-UNTRGE26.js +0 -2
  173. package/out/chunk-UXNUAIIC.js +0 -33
  174. package/out/chunk-UYAXVRVL.js +0 -1
  175. package/out/chunk-V3ME6FI5.js +0 -2
  176. package/out/chunk-V5O7M2YT.js +0 -1
  177. package/out/chunk-VIHGO77Y.js +0 -1
  178. package/out/chunk-VQJCXGIY.js +0 -7
  179. package/out/chunk-W4J3MGLB.js +0 -1
  180. package/out/chunk-WGG7KQUH.js +0 -1
  181. package/out/chunk-WZDTZE64.js +0 -1
  182. package/out/chunk-XOCAPDCM.js +0 -1
  183. package/out/chunk-XYFXXB2G.js +0 -10
  184. package/out/chunk-YVZH5SHP.js +0 -1
  185. package/out/chunk-ZBBOBQ43.js +0 -1
  186. package/out/chunk-ZMQQCGF6.js +0 -2
@@ -1,74 +1,43 @@
1
- // A package's files from its tarball, as npm installs them: gunzipped, read as
2
- // a tar archive (ustar, pax and GNU long names), each file by the path npm
3
- // installs it at, its first component dropped, none escaping the package; a
4
- // text where it is UTF-8 with no NUL, else null. Held to the npm viewer's
5
- // bounds (server-managed/npm-packages.ts readNpmTar).
1
+ // A package's files from its tarball, as it holds them, whatever installs it
2
+ // (npm renames a .gitignore .npmignore, pnpm and yarn keep it): gunzipped and
3
+ // read as a tar archive by @preventive/archive, which refuses one malformed, a
4
+ // name or a symlink that would climb out of it, and a name repeated as another
5
+ // entry; each file by its path there, its first component dropped; a text
6
+ // where it is UTF-8 with no NUL, else null. Held to the npm viewer's bounds
7
+ // (server-managed/npm-packages.ts readNpmTar).
8
+ import { CompressionError, decompress } from '@preventive/archive/compression.js'
9
+ import { ArchiveError, unpack } from '@preventive/archive/tar.js'
10
+
6
11
  const MAX_PACKAGE_FILES = 20_000
7
12
  export const MAX_PACKAGE_BYTES = 64 * 1024 * 1024
13
+ // The entries its tar stream may hold, a directory's too, read before any is
14
+ // counted as a file, as npm-packages.ts bounds them (MAX_NPM_TAR_ENTRIES).
15
+ export const MAX_TAR_ENTRIES = 2 * MAX_PACKAGE_FILES
8
16
  // What its tar stream may unpack to, its headers and padding beside its
9
17
  // files, as npm-loads.ts bounds it (MAX_TAR_BYTES).
10
18
  export const MAX_TAR_BYTES = 96 * 1024 * 1024
11
19
 
12
- const latin1 = new TextDecoder('latin1')
13
20
  const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
14
- const utf8Lenient = bytes => new TextDecoder().decode(bytes)
15
- // A header's text field, to its first NUL.
16
- const textOf = (bytes, start, length) => {
17
- const field = bytes.subarray(start, start + length)
18
- const end = field.indexOf(0)
19
- return utf8Lenient(end === -1 ? field : field.subarray(0, end))
20
- }
21
21
 
22
- // A header's size field: octal, or GNU base-256 past its 11 digits.
23
- function sizeOf(bytes, offset) {
24
- if (bytes[offset] & 0x80) {
25
- let size = bytes[offset] & 0x7f
26
- for (let i = 1; i < 12; i++) size = size * 256 + bytes[offset + i]
27
- return size
28
- }
29
- const text = textOf(bytes, offset, 12).trim()
30
- return /^[0-7]+$/u.test(text) ? Number.parseInt(text, 8) : Number.NaN
31
- }
32
-
33
- // A pax header's path: records `<length> <key>=<value>\n`, their lengths in
34
- // bytes.
35
- function paxPath(data) {
36
- let at = 0, path = null
37
- while (at < data.length) {
38
- const space = data.indexOf(0x20, at)
39
- const length = Number.parseInt(latin1.decode(data.subarray(at, space)), 10)
40
- if (space === -1 || !Number.isSafeInteger(length) || length <= 0) break
41
- const record = utf8Lenient(data.subarray(space + 1, at + length - 1))
42
- if (record.startsWith('path=')) path = record.slice(5)
43
- at += length
44
- }
45
- return path
46
- }
47
-
48
- const packagePath = raw => {
49
- const parts = raw.split('/').slice(1).filter(part => part !== '' && part !== '.')
50
- return parts.length === 0 || parts.includes('..') ? null : parts.join('/')
51
- }
52
-
53
- // The files of a tar archive, by path: their bytes.
22
+ // The files of a tar archive, by path: their bytes. A file at its top is not
23
+ // extracted, and one under another top directory at the same path replaces
24
+ // an earlier one, as extraction would. Past MAX_TAR_ENTRIES it is given up on
25
+ // as `package-too-large`, before any more of it is read.
54
26
  function npmTarFiles(tar) {
27
+ let entries
28
+ try {
29
+ entries = unpack(tar, { maxEntries: MAX_TAR_ENTRIES })
30
+ } catch (error) {
31
+ if (error instanceof ArchiveError && error.limited) throw new Error('package-too-large', { cause: error })
32
+ throw error
33
+ }
55
34
  const files = new Map()
56
- let longName = null, offset = 0, total = 0
57
- while (offset + 512 <= tar.length) {
58
- const header = tar.subarray(offset, offset + 512)
59
- if (header.every(byte => byte === 0)) break
60
- const size = sizeOf(header, 124), start = offset + 512, type = String.fromCodePoint(header[156])
61
- if (!Number.isSafeInteger(size) || size < 0 || start + size > tar.length) throw new Error('bad-tarball')
62
- const data = tar.subarray(start, start + size)
63
- offset = start + Math.ceil(size / 512) * 512
64
- if (type === 'x' || type === 'L') { longName = type === 'x' ? paxPath(data) : textOf(data, 0, data.length); continue }
65
- if (type === 'g' || type === 'K') continue
66
- const prefix = textOf(header, 257, 6) === 'ustar' ? textOf(header, 345, 155) : ''
67
- const name = longName ?? (prefix ? `${prefix}/${textOf(header, 0, 100)}` : textOf(header, 0, 100))
68
- longName = null
69
- const path = ['0', '\0', '7'].includes(type) ? packagePath(name) : null
70
- if (path === null) continue
71
- total += size - (files.get(path)?.length ?? 0)
35
+ let total = 0
36
+ for (const { name, type, data } of entries) {
37
+ const slash = name.indexOf('/')
38
+ if ((type !== 'file' && type !== 'contiguous-file') || slash === -1) continue
39
+ const path = name.slice(slash + 1)
40
+ total += data.length - (files.get(path)?.length ?? 0)
72
41
  files.set(path, data)
73
42
  if (files.size > MAX_PACKAGE_FILES || total > MAX_PACKAGE_BYTES) throw new Error('package-too-large')
74
43
  }
@@ -81,18 +50,31 @@ export function npmFileText(bytes) {
81
50
  try { return utf8.decode(bytes) } catch { return null }
82
51
  }
83
52
 
84
- // A gzipped tarball's tar stream, given up on as `package-too-large` once it
85
- // unpacks past MAX_TAR_BYTES, before any more of it is held.
53
+ // A gzipped tarball's tar stream, through @preventive/archive's gzip, given up
54
+ // on as `package-too-large` once it unpacks past MAX_TAR_BYTES, before any
55
+ // more of it is held.
86
56
  async function gunzip(gzipped) {
87
- const reader = new Blob([gzipped]).stream().pipeThrough(new DecompressionStream('gzip')).getReader()
88
- const chunks = []
89
- let size = 0
90
- for (let read = await reader.read(); !read.done; read = await reader.read()) {
91
- size += read.value.byteLength
92
- if (size > MAX_TAR_BYTES) { await reader.cancel(); throw new Error('package-too-large') }
93
- chunks.push(read.value)
57
+ try {
58
+ return await decompress(gzipped, 'gzip', { limit: MAX_TAR_BYTES })
59
+ } catch (error) {
60
+ if (error instanceof CompressionError && error.limited) throw new Error('package-too-large', { cause: error })
61
+ throw error
94
62
  }
95
- return new Uint8Array(await new Blob(chunks).arrayBuffer())
63
+ }
64
+
65
+ // Whether a file that isn't text may run as JavaScript all the same, as node
66
+ // decodes it as UTF-8 (npm/files.js npmLoadedScripts): only where its first
67
+ // character no UTF-8 encodes, or its first NUL, which JavaScript holds only
68
+ // in a string, a template, a regular expression or a comment, may sit in
69
+ // one, after a quote, a backtick, a slash, a `<` or `-` (an HTML-like
70
+ // comment, `<!--`, `-->`) or on a hashbang's line. An image's, a font's or
71
+ // WebAssembly's is in its first bytes, before any.
72
+ const OPENS = [0x27, 0x22, 0x60, 0x2F, 0x3C, 0x2D]
73
+ function npmRunnable(bytes) {
74
+ const start = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF ? 3 : 0
75
+ if (bytes[start] === 0x23 && bytes[start + 1] === 0x21) return true
76
+ const opens = Math.min(...OPENS.map(byte => bytes.indexOf(byte, start)).filter(at => at !== -1))
77
+ return Number.isFinite(opens) && npmFileText(bytes.subarray(0, opens)) !== null
96
78
  }
97
79
 
98
80
  // A file's bytes' digest, as the managed server gives one of a file that
@@ -103,11 +85,12 @@ const sha256 = async bytes => `sha256-${btoa(String.fromCodePoint(...new Uint8Ar
103
85
  // viewer holds a version (ui/view/npm-package.js npmPackageDetails), a source
104
86
  // map listing them (`kind: 'sourcemap'`): `json.sources` their paths, sorted,
105
87
  // `json.sourcesContent` their texts, `fileSizes` by path, and `npmBinaries`,
106
- // those that aren't text, `{ size, digest }` by path, which compare.js and
88
+ // those that aren't text, `{ size, digest, runnable }` by path (npmRunnable),
89
+ // which compare.js and
107
90
  // diff.js tell them by.
108
91
  export async function npmTarballVersion(gzipped) {
109
92
  const files = [...npmTarFiles(await gunzip(gzipped))].toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
110
93
  const texts = files.map(([, bytes]) => npmFileText(bytes))
111
- const binaries = await Promise.all(files.filter((_, at) => texts[at] === null).map(async ([path, bytes]) => [path, { size: bytes.length, digest: await sha256(bytes) }]))
94
+ const binaries = await Promise.all(files.filter((_, at) => texts[at] === null).map(async ([path, bytes]) => [path, { size: bytes.length, digest: await sha256(bytes), runnable: npmRunnable(bytes) }]))
112
95
  return { kind: 'sourcemap', json: { version: 3, sources: files.map(([path]) => path), sourcesContent: texts }, fileSizes: new Map(files.map(([path, bytes]) => [path, bytes.length])), npmBinaries: new Map(binaries) }
113
96
  }
@@ -0,0 +1,198 @@
1
+ // The commits agents' pull requests brought to a repository's default
2
+ // branch, for its Pulse (repository-pulse.ts commitStats): an agent that
3
+ // commits as whoever ran it (Codex's own) leaves its commits no mark, its
4
+ // pull request's branch alone naming it (app-agents.ts
5
+ // AGENT_BRANCH_PREFIXES), or a label one who ran it gave it naming the agent
6
+ // (agent-tasks.ts labelAgents), as the Agents tab tells an agent's work; or
7
+ // its author, an account agents work as (github-agents.ts AGENT_OPERATED).
8
+ import { labelAgents } from '../common/managed/agent-tasks.ts'
9
+ import { GITHUB_AGENTS, agentOperated } from '../common/managed/github-agents.ts'
10
+ import { AGENT_BRANCH_PREFIXES, APP_AGENTS } from './app-agents.ts'
11
+ import { type HistoryCommit, githubGraphql } from './repository-history.ts'
12
+
13
+ // The merged pull requests read an answer, a page a query, as many queries
14
+ // as MERGED_QUERIES, the rest left for the next to ask; of each, as many of
15
+ // its latest commits as MERGED_COMMITS.
16
+ const MERGED_PAGE = 100
17
+ const MERGED_QUERIES = 5
18
+ const MERGED_COMMITS = 100
19
+ // Of each, as many labels as MERGED_LABELS, GitHub's most a page, a label
20
+ // naming any agent the app knows making it an agent's.
21
+ const MERGED_LABELS = 100
22
+ // How long before or after a pull request rebased onto the branch was merged
23
+ // its copies are committed.
24
+ const REBASE_SKEW_MS = 10 * 60_000
25
+ const LABEL_AGENTS = [...new Set([...Object.keys(GITHUB_AGENTS), ...APP_AGENTS.map(agent => agent.id)])]
26
+ const SHA = /^[a-f\d]{40}$/u
27
+
28
+ const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
29
+ const timeOf = (value: unknown) => {
30
+ const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
31
+ return Number.isSafeInteger(at) ? at : null
32
+ }
33
+
34
+ // The agents' pull requests merged since a year's start, as read so far
35
+ // (agentMerges): the commits each brought, and when it was merged; those
36
+ // rebased onto the branch by their last commit there, when merged, and
37
+ // their commits' subjects, the oldest first (agentCommits); when the newest
38
+ // read was last updated, those updated since read again (`through`, null
39
+ // till all are read once); and a read not finished: where it goes on
40
+ // (`after`), when the newest it began with was updated (`from`), the oldest
41
+ // it reads to (`stop`) and how many it has read (`read`). And of every pull
42
+ // request merged, an agent's or not, its merge commit and when (`pulls`),
43
+ // and one rebased so (`pullRebases`), for the commits pushed to the branch
44
+ // directly to be told (directCommits).
45
+ export interface AgentMerges { merges: [string, number][]; rebases: Rebase[]; through: number | null
46
+ next: { after: string; from: number; stop: number; read: number } | null; pulls?: [string, number][]; pullRebases?: Rebase[] }
47
+ // A pull request rebased onto the branch: its last commit there, when it was
48
+ // merged, its latest commits' subjects, the oldest first, and how many more
49
+ // it had than those read (`more`).
50
+ type Rebase = [sha: string, at: number, subjects: string[], more?: number]
51
+
52
+ const MERGED_QUERY = `query($owner: String!, $name: String!, $after: String) {
53
+ repository(owner: $owner, name: $name) {
54
+ pullRequests(states: MERGED, first: ${MERGED_PAGE}, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
55
+ pageInfo { hasNextPage endCursor }
56
+ nodes { updatedAt mergedAt headRefName author { login ... on User { databaseId } } labels(first: ${MERGED_LABELS}) { nodes { name } }
57
+ mergeCommit { oid parents { totalCount } }
58
+ commits(last: ${MERGED_COMMITS}) { totalCount nodes { commit { oid messageHeadline changedFilesIfAvailable } } } }
59
+ }
60
+ }
61
+ }`
62
+ type MergedPage = { repository?: { pullRequests?: { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }
63
+ nodes?: ({ updatedAt?: unknown; mergedAt?: unknown; headRefName?: unknown; author?: { login?: unknown; databaseId?: unknown } | null
64
+ labels?: { nodes?: ({ name?: unknown } | null)[] } | null
65
+ mergeCommit?: { oid?: unknown; parents?: { totalCount?: unknown } | null } | null
66
+ commits?: { totalCount?: unknown; nodes?: ({ commit?: { oid?: unknown; messageHeadline?: unknown; changedFilesIfAvailable?: unknown } | null } | null)[] } | null
67
+ } | null)[] } | null } | null }
68
+ const agentBranch = (name: unknown) => typeof name === 'string' && AGENT_BRANCH_PREFIXES.some(prefix => name.startsWith(prefix))
69
+ type MergedNode = NonNullable<NonNullable<NonNullable<NonNullable<MergedPage['repository']>['pullRequests']>['nodes']>[number]>
70
+ // Whether a pull request is an agent's: by its branch, a label naming one,
71
+ // or its author, an account agents work as.
72
+ const agentPull = ({ headRefName, labels, author }: MergedNode) => agentBranch(headRefName)
73
+ || labelAgents((labels?.nodes ?? []).map(label => label?.name), LABEL_AGENTS).length > 0
74
+ || agentOperated(typeof author?.login === 'string' ? author.login : null, Number.isSafeInteger(author?.databaseId) ? author!.databaseId as number : null)
75
+
76
+ // The commits the pull requests merged since `since` from an agent's branch,
77
+ // labelled for one, or opened by an account agents work as, brought: each its merge commit, a squash merge's one
78
+ // commit; where it was
79
+ // merged with a merge commit, which a year counts none of, its own commits,
80
+ // its latest MERGED_COMMITS; and of one of more commits whose merge commit
81
+ // has one parent, its commits' subjects, for those it was rebased as to be
82
+ // told by (agentCommits), those of its empty commits left out, as a rebase
83
+ // leaves them. Those merged are read the most recently updated
84
+ // first (a pull request is updated when merged, or after), on from what was
85
+ // `kept`: till one updated before the year, or before the newest read before
86
+ // was, as many queries an answer as MERGED_QUERIES, a read not finished
87
+ // going on from where it was left by the next; one read again whose label
88
+ // no longer names an agent, what it was read to bring forgotten.
89
+ export async function agentMerges(fullName: string, token: string, since: number, kept: AgentMerges | null,
90
+ fetchImpl: typeof fetch = globalThis.fetch): Promise<AgentMerges> {
91
+ const [owner, name] = fullName.split('/') as [string, string]
92
+ const merges = new Map((kept?.merges ?? []).filter(([, at]) => at >= since))
93
+ const rebases = new Map((kept?.rebases ?? []).filter(([, at]) => at >= since).map(([sha, at, subjects, more = 0]) => [sha, [at, subjects, more] as const]))
94
+ const pulls = new Map((kept?.pulls ?? []).filter(([, at]) => at >= since))
95
+ const pullRebases = new Map((kept?.pullRebases ?? []).filter(([, at]) => at >= since).map(([sha, at, subjects, more = 0]) => [sha, [at, subjects, more] as const]))
96
+ const stop = kept?.next?.stop ?? Math.max(since, kept?.through ?? since)
97
+ const listed = (map: typeof rebases) => [...map].map(([sha, [at, subjects, more]]): Rebase => more > 0 ? [sha, at, subjects, more] : [sha, at, subjects])
98
+ const result = (through: number | null, next: AgentMerges['next']): AgentMerges => ({ merges: [...merges], rebases: listed(rebases), through, next,
99
+ pulls: [...pulls], pullRebases: listed(pullRebases) })
100
+ let { after = null, from = null, read = 0 }: { after?: string | null; from?: number | null; read?: number } = kept?.next ?? {}
101
+ for (let queries = 0; queries < MERGED_QUERIES; queries++) {
102
+ const page = (await githubGraphql(MERGED_QUERY, { owner, name, after }, token, fetchImpl) as MergedPage | null)?.repository?.pullRequests
103
+ let done = page?.pageInfo?.hasNextPage !== true || typeof page.pageInfo.endCursor !== 'string'
104
+ for (const node of page?.nodes ?? []) {
105
+ const updated = timeOf(node?.updatedAt)
106
+ if (updated === null) continue
107
+ if (updated < stop) { done = true; break }
108
+ from ??= updated
109
+ read++
110
+ const [merged, merge] = [timeOf(node?.mergedAt), node?.mergeCommit?.oid]
111
+ if (!node || merged === null || merged < since || typeof merge !== 'string' || !SHA.test(merge)) continue
112
+ const own = (node.commits?.nodes ?? []).map(item => item?.commit)
113
+ const merging = count(node.mergeCommit?.parents?.totalCount) > 1
114
+ const brought = [merge, ...merging ? own.map(commit => commit?.oid) : []].filter((oid): oid is string => typeof oid === 'string' && SHA.test(oid))
115
+ const copied = own.filter(commit => commit?.changedFilesIfAvailable !== 0)
116
+ // Of one of more commits than its latest read, how many more it had.
117
+ const more = Math.max(0, count(node.commits?.totalCount) - own.length)
118
+ const subjects = !merging && (copied.length > 1 || more > 0) && copied.length > 0 && copied.every(commit => typeof commit?.messageHeadline === 'string')
119
+ ? copied.map(commit => commit!.messageHeadline as string) : null
120
+ pulls.set(merge, merged)
121
+ if (subjects) pullRebases.set(merge, [merged, subjects, more])
122
+ // One an agent's no more, its label taken off (which updates it), what
123
+ // it was read to bring none of an agent's.
124
+ if (!agentPull(node)) {
125
+ for (const oid of brought.filter(sha => merges.get(sha) === merged)) merges.delete(oid)
126
+ if (rebases.get(merge)?.[0] === merged) rebases.delete(merge)
127
+ continue
128
+ }
129
+ for (const oid of brought) merges.set(oid, merged)
130
+ if (subjects) rebases.set(merge, [merged, subjects, more])
131
+ }
132
+ if (done) return result(from ?? stop, null)
133
+ after = page!.pageInfo!.endCursor as string
134
+ }
135
+ return result(kept?.through ?? null, { after: after!, from: from ?? stop, stop, read })
136
+ }
137
+
138
+ // Whether two reads of the pull requests merged (agentMerges) bring the
139
+ // same commits, the agents' and every one's (`pulls`, `pullRebases`, which
140
+ // tell those pushed directly), whatever order each read them in.
141
+ export function sameMerges(a: AgentMerges, b: AgentMerges) {
142
+ const sorted = (entries: unknown[] = []) => entries.map(entry => JSON.stringify(entry)).toSorted()
143
+ const brought = (read: AgentMerges) => JSON.stringify([read.merges, read.rebases, read.pulls, read.pullRebases].map(sorted))
144
+ return brought(a) === brought(b)
145
+ }
146
+
147
+ // A commit's subject as GitHub heads its message: whole, or, past what it
148
+ // shows, cut short before an ellipsis.
149
+ const headed = (subject: string, headline: string) => headline.endsWith('…') ? subject.startsWith(headline.slice(0, -1)) : subject === headline
150
+
151
+ // The commits of `commits` (a year's) the agents' pull requests brought
152
+ // (`read`, agentMerges): those it names, and of one rebased, its commits'
153
+ // copies, the last and those below it on their first parents, as long as
154
+ // each one's subject is that of one of its commits older than the last
155
+ // matched, as Commits tells a rebase (repository-history.ts squashedPulls),
156
+ // past those of its commits the rebase left none of (an empty one GitHub
157
+ // counted no files of), and, past them all, as many more as it had before
158
+ // its latest read; a squash merge's one commit alone, its subject the pull
159
+ // request's own.
160
+ export function agentCommits(read: AgentMerges, commits: HistoryCommit[]): Set<string> {
161
+ const shas = new Set(read.merges.map(([sha]) => sha))
162
+ const bySha = new Map(commits.map(commit => [commit.sha, commit]))
163
+ for (const [last, merged, subjects, more = 0] of read.rebases) {
164
+ let commit = bySha.get(last)
165
+ let at = subjects.length - 1
166
+ for (; commit && at >= 0; commit = bySha.get(commit.parents[0] ?? '')) {
167
+ const [copied, upTo] = [commit, at]
168
+ const found = subjects.findLastIndex((subject, index) => index <= upTo && headed(copied.subject, subject))
169
+ if (found < 0) break
170
+ shas.add(commit.sha)
171
+ at = found - 1
172
+ }
173
+ // Each of its latest commits read copied, as many as it had before them
174
+ // the commits below, on their first parents, as long as each was
175
+ // committed as it was merged, as a rebase commits its copies: not past
176
+ // its first, its empty ones, which a rebase leaves out, counted too.
177
+ const copiedAsMerged = (when: number | null) => when !== null && Math.abs(when - merged) <= REBASE_SKEW_MS
178
+ for (let rest = at < 0 ? more : 0; commit && rest > 0 && copiedAsMerged(commit.committedAt); rest--, commit = bySha.get(commit.parents[0] ?? '')) {
179
+ shas.add(commit.sha)
180
+ }
181
+ }
182
+ return shas
183
+ }
184
+
185
+ // The commits of `commits` (a year's, from the branch's `head`) pushed to
186
+ // it directly: on its first parents from the head, none a merge, none a
187
+ // pull request brought (`read.pulls`: its merge commit, a squash's one
188
+ // commit, or the copies of one rebased onto it, as agentCommits tells
189
+ // them); those a merge brought from its other parents, not.
190
+ export function directCommits(read: AgentMerges, commits: HistoryCommit[], head: string): Set<string> {
191
+ const pulled = agentCommits({ ...read, merges: read.pulls ?? [], rebases: read.pullRebases ?? [] }, commits)
192
+ const bySha = new Map(commits.map(commit => [commit.sha, commit]))
193
+ const direct = new Set<string>()
194
+ for (let commit = bySha.get(head); commit; commit = bySha.get(commit.parents[0] ?? '')) {
195
+ if (commit.parents.length <= 1 && !pulled.has(commit.sha)) direct.add(commit.sha)
196
+ }
197
+ return direct
198
+ }
@@ -0,0 +1,30 @@
1
+ // The agents the server knows by their GitHub Apps or accounts (the Agents
2
+ // tab's, server-managed/repository-agents.ts, whose marks the Commits tab
3
+ // reads on commits too, repository-history.ts).
4
+ import type { AppAgent, AppAgentMarks } from '../common/managed/agent-tasks.ts'
5
+ import { GITHUB_AGENTS } from '../common/managed/github-agents.ts'
6
+
7
+ // The agents known by their GitHub Apps or accounts, beside Claude and
8
+ // Codex: each its id and name, its avatar (its mark, served by
9
+ // /api/avatar/<avatar>, for these alone: a GitHub App's, `app-<id>`, or
10
+ // account's, `user-<id>`), the fingerprint it leaves as a line of its pull
11
+ // requests' bodies and its commits' messages, the account its work is
12
+ // authored by (its id and the logins GitHub names it by, told by both), and
13
+ // its branches' prefix. Their work may carry Claude's or
14
+ // Codex's marks too, those its tools. Copilot's coding agent opens its pull
15
+ // requests and authors its commits as its account, Copilot (198982749), on
16
+ // `copilot/` branches; Dependabot, its GitHub App (29110), opens its pull
17
+ // requests as its account, `dependabot` (49699333, `dependabot[bot]` to
18
+ // GitHub's REST API and in its address), on `dependabot/<ecosystem>/…`
19
+ // branches. Their accounts and marks are those the app draws their work by
20
+ // wherever it shows it (common/managed/github-agents.ts): Copilot's its own,
21
+ // the app's to draw, no avatar.
22
+ const { copilot, dependabot } = GITHUB_AGENTS
23
+ export const APP_AGENTS: readonly (AppAgent & AppAgentMarks)[] = [
24
+ { id: 'neo', name: 'Neo', avatar: 'app-3115942', fingerprint: '🤖 Generated with [Neo](', prefix: 'neo/' },
25
+ { id: 'copilot', name: copilot.name, account: copilot.accounts[0], prefix: 'copilot/' },
26
+ { id: 'dependabot', name: dependabot.name, avatar: dependabot.avatar, account: dependabot.accounts[0], prefix: 'dependabot/' },
27
+ ]
28
+ // The prefixes of the branches agents work on: Claude Code's and Codex's,
29
+ // and the app agents'.
30
+ export const AGENT_BRANCH_PREFIXES = ['claude/', 'codex/', ...APP_AGENTS.map(agent => agent.prefix)]
@@ -9,4 +9,5 @@ export interface CachedAvatar {
9
9
  export interface AvatarStore {
10
10
  put(id: string, contentType: string, bytes: Buffer): Promise<void>
11
11
  get(id: string): Promise<CachedAvatar | null>
12
+ delete(id: string): Promise<void>
12
13
  }
@@ -0,0 +1,83 @@
1
+ // Who made a commit beside its author, as the Commits tab shows them
2
+ // (repository-history.ts): its co-authors, as its `Co-authored-by:` trailers
3
+ // name them, and the agents its message marks.
4
+ import { agentSignals, trailerBlock } from '../common/managed/agent-tasks.ts'
5
+ import { GITHUB_AGENTS, type GithubAgentId, emailAgent, githubAgent } from '../common/managed/github-agents.ts'
6
+ import { APP_AGENTS } from './app-agents.ts'
7
+ import { type NamedAccount, commitAccount } from './github-accounts.ts'
8
+
9
+ export const MAX_COAUTHORS = 3
10
+ // The rules co-authors are read by, the agents' addresses and names among
11
+ // them: what was kept read by others (history pages, squashed pull requests)
12
+ // read again by these, once raised.
13
+ export const COAUTHORS_READ = 2
14
+ const MAX_NAME = 256
15
+ const textOf = (value: unknown, max: number) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null
16
+
17
+ // One who made a commit beside its author: by name and account, as its
18
+ // author is, and the agent it is where known, one GitHub knows by its account
19
+ // or its commits' address or name (common/managed/github-agents.ts) or one
20
+ // the server knows by its app (app-agents.ts), with its avatar.
21
+ export interface Coauthor { name: string | null; login: string | null; id: number | null; agent?: string; avatar?: string }
22
+
23
+ // `Co-authored-by: <name> <address>` trailers, GitHub matching the key
24
+ // regardless of case; Cursor's agents' links, as their work carries them;
25
+ // the app agents known by the fingerprints they leave.
26
+ const COAUTHOR_TRAILER = /^co-authored-by:[ \t]*([^<\n]*?)[ \t]*<([^<>\s]+)>[ \t]*$/gimu
27
+ const CURSOR_AGENT = /https:\/\/cursor\.com\/(?:agents\?id=|background-agent\?bcId=)bc-/u
28
+ const FINGERPRINTED = APP_AGENTS.filter(agent => agent.fingerprint)
29
+
30
+ // One named as a commit's author or co-author: by name, the address its
31
+ // commit gives (where it is read), and account.
32
+ export type Maker = { name: unknown; email?: unknown; account: NamedAccount }
33
+
34
+ // The agent one who made a commit is: by their account, else the address
35
+ // their commit names them by; else, GitHub naming no account for them, by
36
+ // their name.
37
+ const makerAgent = ({ name, email, account }: Maker): GithubAgentId | null => githubAgent(account.login, account.id) ?? emailAgent(email)
38
+ ?? (account.login ? null : githubAgent(null, null, typeof name === 'string' ? name : null))
39
+
40
+ // Who made a commit, past its author (`author`), as its message names them:
41
+ // its co-authors, those GitHub's GraphQL API lists past the author (`listed`,
42
+ // the addresses its `Co-authored-by:` trailers give, by the accounts GitHub
43
+ // knows them by), else its trailers (agent-tasks.ts trailerBlock), an account's no-reply
44
+ // address naming it;
45
+ // then the agents it marks, Claude's and Codex's as the Agents tab reads them
46
+ // (agent-tasks.ts agentSignals: their sessions' and tasks' links, Claude's
47
+ // co-author and "Generated with" lines), the app agents' by their
48
+ // fingerprints, Cursor's by its agents' links. Each the agent it is where
49
+ // known, and once, the author none of them, as many as MAX_COAUTHORS: two
50
+ // are one by the first of their agent, account, address and name both name,
51
+ // two people of one name with accounts or addresses of their own two.
52
+ export function coauthorsOf(message: unknown, author: Maker, listed: Maker[] | null = null): Coauthor[] {
53
+ const text = typeof message === 'string' ? message.replaceAll('\r\n', '\n') : ''
54
+ const named = listed ?? [...trailerBlock(text).matchAll(COAUTHOR_TRAILER)].map(([, name, email]) => ({ name, email, account: commitAccount({ email }) }))
55
+ const signals = agentSignals([text], FINGERPRINTED)
56
+ const marked = [...signals.claude || signals.sessions.length > 0 ? ['claude'] : [], ...signals.tasks.length > 0 || signals.codex ? ['codex'] : [],
57
+ ...CURSOR_AGENT.test(text) ? ['cursor'] : [], ...signals.agents]
58
+ type Identity = { agent: string | null; login: string | null; email: string | null; name: string | null }
59
+ const identity = (agent: string | null, login: string | null, name: string | null, email: unknown = null): Identity => ({ agent, login: login?.toLowerCase() ?? null,
60
+ email: typeof email === 'string' && email.trim() ? email.trim().toLowerCase() : null, name: name?.toLowerCase() ?? null })
61
+ const same = (a: Identity, b: Identity) => a.agent && b.agent ? a.agent === b.agent : a.login && b.login ? a.login === b.login
62
+ : a.email && b.email ? a.email === b.email : a.name !== null && a.name === b.name
63
+ const seen = [identity(makerAgent(author), author.account.login, textOf(author.name, MAX_NAME), author.email)]
64
+ const coauthors: Coauthor[] = []
65
+ const add = (coauthor: Coauthor, email: unknown = null) => {
66
+ const who = identity(coauthor.agent ?? null, coauthor.login, coauthor.name, email)
67
+ if (seen.some(other => same(other, who)) || coauthors.length >= MAX_COAUTHORS || (!coauthor.login && !coauthor.name && !coauthor.agent)) return
68
+ seen.push(who)
69
+ coauthors.push(coauthor)
70
+ }
71
+ for (const maker of named) {
72
+ const agent = makerAgent(maker)
73
+ // An agent known by its address where GitHub names someone else's
74
+ // account for it (Codex's), as itself, not that account.
75
+ const { login, id } = agent && maker.account.login && githubAgent(maker.account.login, maker.account.id) !== agent ? { login: null, id: null } : maker.account
76
+ add({ name: textOf(maker.name, MAX_NAME), login, id, ...agent ? { agent } : {} }, maker.email)
77
+ }
78
+ for (const agent of marked) {
79
+ const app = APP_AGENTS.find(known => known.id === agent && !Object.hasOwn(GITHUB_AGENTS, known.id))
80
+ add({ name: app?.name ?? GITHUB_AGENTS[agent as GithubAgentId].name, login: null, id: null, agent, ...app?.avatar ? { avatar: app.avatar } : {} })
81
+ }
82
+ return coauthors
83
+ }
@@ -0,0 +1,62 @@
1
+ // The versions a repository's lockfiles of npm's ecosystem lock at a commit,
2
+ // as their review reads them (pull-dependency-reviews.ts): every lockfile
3
+ // there (findRepoLockfiles) a review holds to (`held`, lockfile-scope.ts),
4
+ // read as a pull request's are (pull-dependencies.ts readLockfile, kept in
5
+ // this instance by the commit and path), as many as MAX_LOCKFILES of those,
6
+ // each's versions (`lockfiles`), and those its reader refuses, of Bun's, or
7
+ // past MAX_LOCKFILES (`unread`); and those of some of them (lockedIn), each
8
+ // version once by what its reviews are held to (lockedWith), dev only where
9
+ // it is wherever it is locked. GitHub having none of a lockfile it lists
10
+ // there fails the whole, as it would for any file.
11
+ import { createClient } from '@preventive/upstream/github.js'
12
+ import { upstreamGithub } from './github-errors.ts'
13
+ import { GithubApiError } from './github-app.ts'
14
+ import type { LockedPackage } from './locked-packages.ts'
15
+ import { type Lru, lru } from './lru.ts'
16
+ import { lockedWith, lockfileFormat, readLockfile } from './pull-dependencies.ts'
17
+
18
+ export const MAX_LOCKFILES = 100
19
+
20
+ export interface CommitLockfiles { lockfiles: { path: string; packages: LockedPackage[] }[]; unread: string[] }
21
+
22
+ // The lockfiles at commit `sha` its review reads, by their paths, each with
23
+ // its format (pull-dependencies.ts lockfileFormat), kept by the commit.
24
+ const listings: Lru<{ path: string; format: NonNullable<ReturnType<typeof lockfileFormat>> }[]> = lru(20_000, listed => listed.length + 1)
25
+ export async function lockfilesAt(fullName: string, token: string, sha: string) {
26
+ const key = `${fullName}\0${sha}`
27
+ const known = listings.get(key)
28
+ if (known) return known
29
+ const { lockfiles } = await upstreamGithub(() => createClient({ token, userAgent: 'deepview-triage' }).findRepoLockfiles({ repo: fullName, sha }))
30
+ const listed = lockfiles.flatMap(({ path }) => { const format = lockfileFormat(path); return format ? [{ path, format }] : [] })
31
+ listings.set(key, listed)
32
+ return listed
33
+ }
34
+
35
+ export async function lockedAt(fullName: string, token: string, sha: string, held: (path: string) => boolean = () => true): Promise<CommitLockfiles> {
36
+ const listed = (await lockfilesAt(fullName, token, sha)).filter(({ path }) => held(path))
37
+ const unread = listed.slice(MAX_LOCKFILES).map(({ path }) => path)
38
+ const lockfiles: CommitLockfiles['lockfiles'] = []
39
+ for (const lockfile of listed.slice(0, MAX_LOCKFILES)) {
40
+ const read = await readLockfile(fullName, token, sha, lockfile)
41
+ if ('error' in read && read.error === 'github-not-found') throw new GithubApiError(404, 'github-not-found')
42
+ if ('error' in read) unread.push(lockfile.path)
43
+ else lockfiles.push({ path: lockfile.path, packages: read.packages })
44
+ }
45
+ return { lockfiles, unread: unread.toSorted() }
46
+ }
47
+
48
+ // The versions those of `read`'s lockfiles `held` holds to lock, each once,
49
+ // and their paths.
50
+ export function lockedIn(read: CommitLockfiles, held: (path: string) => boolean = () => true) {
51
+ const packages = new Map<string, LockedPackage>()
52
+ const paths: string[] = []
53
+ for (const lockfile of read.lockfiles.filter(({ path }) => held(path))) {
54
+ paths.push(lockfile.path)
55
+ for (const pkg of lockfile.packages) {
56
+ const kept = packages.get(lockedWith(pkg))
57
+ if (!kept) packages.set(lockedWith(pkg), { ...pkg })
58
+ else if (kept.dev && !pkg.dev) delete kept.dev
59
+ }
60
+ }
61
+ return { packages: [...packages.values()], paths }
62
+ }
@@ -0,0 +1,41 @@
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):
3
+ // their files paired, at one path or renamed, with the sizes, resolutions,
4
+ // dependency versions and line counts its Overview and Code read beside them,
5
+ // as common/bundle-comparison.js prepareComparison works them out. The viewer
6
+ // of a managed bundle or a package has every comparison worked out here and
7
+ // loads none of the pairing itself. Answered as Brotli-encoded JSON, its
8
+ // `files.basePaths` (a Map) as `[path, as laid out]` pairs where the two
9
+ // sides' roots differ and none else, as the viewer reads none then. Access is
10
+ // the caller's to check.
11
+ import { Buffer } from 'node:buffer'
12
+ import { Readable } from 'node:stream'
13
+ import { prepareComparison } from '../common/bundle-comparison.js'
14
+ import type { BundleDetails } from '../common/bundle-metadata.js'
15
+ import type { OpenedBlob } from './blob-store.ts'
16
+ import { encodeBrotli } from './brotli.ts'
17
+ import { type NpmPackageFile, npmFileRows } from './npm-packages.ts'
18
+
19
+ // A side with no files, as Review compares a package's first version with:
20
+ // one for each comparison, as working one out keeps its line counts on it.
21
+ export const emptySide = (): BundleDetails => ({ integrity: '(empty)', kind: 'sourcemap', size: 0, json: { version: 3, sources: [], sourcesContent: [] } })
22
+
23
+ // A version's files as the viewer reads them for a comparison (ui/view/npm-package.js
24
+ // npmPackageDetails): its texts, and those that are not text by their size and digest.
25
+ export function npmComparedSide(integrity: string, files: NpmPackageFile[]): BundleDetails & { npmBinaries: Map<string, { size: number; digest: string }> } {
26
+ const rows = npmFileRows(files)
27
+ return {
28
+ integrity, kind: 'sourcemap', size: 0,
29
+ json: { version: 3, sources: rows.map(([path]) => path), sourcesContent: rows.map(([, , text]) => text) },
30
+ npmBinaries: new Map(rows.flatMap(([path, size, text, digest]) => (text === null ? [[path, { size, digest: digest! }] as const] : []))),
31
+ }
32
+ }
33
+
34
+ // The comparison of `base` with `other`, within a reason `scope` where given, as sent.
35
+ export async function comparisonBody(base: BundleDetails, other: BundleDetails, scope = ''): Promise<OpenedBlob> {
36
+ const comparison = prepareComparison(base, other, scope)
37
+ const { files } = comparison
38
+ const json = JSON.stringify({ ...comparison, files: { ...files, basePaths: files.root ? [...files.basePaths] : [] } })
39
+ const body = await encodeBrotli(Buffer.from(json))
40
+ return { size: body.byteLength, stream: Readable.from([body]) }
41
+ }