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

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 (366) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +3 -0
  6. package/client/dependency-paths.js +36 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +115 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +357 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-compare-versions.js +172 -0
  17. package/common/bundle-comparison.d.ts +8 -0
  18. package/common/bundle-comparison.js +62 -0
  19. package/common/bundle-metadata.d.ts +15 -0
  20. package/common/bundle-metadata.js +226 -0
  21. package/common/bundle-pkg-of.d.ts +1 -0
  22. package/common/bundle-pkg-of.js +90 -0
  23. package/common/bundle-reasons.d.ts +2 -0
  24. package/common/bundle-reasons.js +21 -0
  25. package/common/bundle-source-links.d.ts +6 -0
  26. package/common/bundle-source-links.js +216 -0
  27. package/common/bundle-sourcemap.d.ts +10 -0
  28. package/common/bundle-sourcemap.js +100 -0
  29. package/common/bundle-sources.d.ts +7 -0
  30. package/common/bundle-sources.js +333 -0
  31. package/common/bundle-stats.js +41 -0
  32. package/common/bundle-tabs.js +1 -0
  33. package/common/code-language.js +65 -0
  34. package/common/default-scan-models.ts +31 -0
  35. package/common/dependency-reviews.d.ts +18 -0
  36. package/common/dependency-reviews.js +64 -0
  37. package/common/finding-duplicates.js +58 -0
  38. package/common/finding-filters.js +274 -0
  39. package/common/finding-id.js +47 -0
  40. package/common/github-issue-labels.d.ts +1 -0
  41. package/common/github-issue-labels.js +11 -0
  42. package/common/github-pr.ts +56 -0
  43. package/common/gzip.js +59 -0
  44. package/common/line-diff.d.ts +8 -0
  45. package/common/line-diff.js +57 -0
  46. package/common/linked-workspace-groups.js +63 -0
  47. package/common/managed/agent-tasks.ts +292 -0
  48. package/common/managed/comments.ts +34 -0
  49. package/common/managed/finding-detail.d.ts +2 -0
  50. package/common/managed/finding-detail.js +32 -0
  51. package/common/managed/github-agents.ts +114 -0
  52. package/common/managed/github-logins.ts +49 -0
  53. package/common/managed/npm-packages.d.ts +5 -0
  54. package/common/managed/npm-packages.js +34 -0
  55. package/common/managed/permissions.ts +31 -0
  56. package/common/managed/report-content.ts +72 -0
  57. package/common/managed/report-filter.ts +109 -0
  58. package/common/managed/repository-alias.ts +65 -0
  59. package/common/managed/roles.ts +35 -0
  60. package/common/managed/routes.d.ts +2 -0
  61. package/common/managed/routes.js +347 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +5 -0
  64. package/common/managed/team-app.js +31 -0
  65. package/common/managed/triage.ts +83 -0
  66. package/common/managed/upload-seal.ts +126 -0
  67. package/common/pretty-print.d.ts +5 -0
  68. package/common/pretty-print.js +24 -0
  69. package/common/revalidation-conflicts.js +48 -0
  70. package/common/revalidation-input-groups.js +56 -0
  71. package/common/save-error-reason.ts +20 -7
  72. package/common/scan-server.ts +13 -0
  73. package/common/security-links.d.ts +5 -0
  74. package/common/security-links.js +33 -0
  75. package/common/server-info.ts +46 -0
  76. package/common/utf8.d.ts +3 -0
  77. package/common/utf8.js +45 -0
  78. package/common/workspace-app.js +39 -0
  79. package/common/workspace-groups.js +176 -0
  80. package/out/activity.js +4 -0
  81. package/out/brotli-fallback.js +3 -3
  82. package/out/chunk-2WDZPXVL.js +3 -0
  83. package/out/chunk-2XUEZHIP.js +28 -0
  84. package/out/chunk-2ZDZWLFY.js +1 -0
  85. package/out/chunk-4HI2MSAV.js +17 -0
  86. package/out/chunk-4XMP7YVH.js +36 -0
  87. package/out/chunk-6LNQTKAV.js +52 -0
  88. package/out/chunk-6S2U2UYG.js +232 -0
  89. package/out/chunk-75YLHBCM.js +3 -0
  90. package/out/chunk-7BKKYVWR.js +7 -0
  91. package/out/chunk-7C7DWL7I.js +1 -0
  92. package/out/chunk-7VAWINQF.js +6 -0
  93. package/out/chunk-C4Z6ZBXV.js +10 -0
  94. package/out/chunk-FC65TPUB.js +1 -0
  95. package/out/chunk-FKBXNUW4.js +1 -0
  96. package/out/chunk-GDPCLSOS.js +2 -0
  97. package/out/chunk-GO7FT4OD.js +1 -0
  98. package/out/chunk-HTBYHSPG.js +1 -0
  99. package/out/chunk-IYPY47MC.js +1 -0
  100. package/out/chunk-IZ7POWI3.js +18 -0
  101. package/out/chunk-JCIIBLKR.js +1 -0
  102. package/out/chunk-NRPC74AM.js +1 -0
  103. package/out/chunk-OOJM4CTU.js +1 -0
  104. package/out/chunk-PBZPYIG7.js +1 -0
  105. package/out/chunk-PNHBZGDD.js +1 -0
  106. package/out/chunk-PWFUOLZK.js +17 -0
  107. package/out/chunk-PXK7EU4V.js +1 -0
  108. package/out/chunk-QB4CBOX2.js +1 -0
  109. package/out/chunk-R3NZFXVR.js +1 -0
  110. package/out/chunk-R5O6FAW4.js +2 -0
  111. package/out/chunk-SXV2T5C4.js +80 -0
  112. package/out/chunk-TQJDYHVE.js +2 -0
  113. package/out/chunk-TUII34K2.js +1 -0
  114. package/out/chunk-UK7RGJT6.js +3 -0
  115. package/out/chunk-VFWMQ3LG.js +2 -0
  116. package/out/chunk-VH4XMZIW.js +2 -0
  117. package/out/chunk-WCPJDAYA.js +10 -0
  118. package/out/chunk-WFYLMF6Z.js +10 -0
  119. package/out/chunk-WYLQWYLA.js +1 -0
  120. package/out/client-managed-import.js +4 -0
  121. package/out/client-managed.js +33 -0
  122. package/out/client-sync.js +11 -17
  123. package/out/compare.js +35 -0
  124. package/out/dialogs.js +55 -0
  125. package/out/github-issue-authorized.html +3 -0
  126. package/out/github-issue-authorized.js +1 -0
  127. package/out/graph.js +17 -4
  128. package/out/index.html +90 -8
  129. package/out/prism.js +14 -10
  130. package/out/scan.js +2 -0
  131. package/out/stasis.svg +20 -0
  132. package/out/terminal.js +366 -39
  133. package/out/view.css +1 -1
  134. package/out/view.js +19 -144
  135. package/package.json +362 -59
  136. package/risk/actions/findings.js +161 -0
  137. package/risk/actions/resolve.js +219 -0
  138. package/risk/actions/scripts/commands.js +182 -0
  139. package/risk/actions/scripts/grade.js +113 -0
  140. package/risk/actions/scripts/packages.js +123 -0
  141. package/risk/actions/scripts/shell.js +169 -0
  142. package/risk/actions/uses.js +76 -0
  143. package/risk/actions/workflow.js +110 -0
  144. package/risk/files/characters.js +196 -0
  145. package/risk/files/hosts.js +205 -0
  146. package/risk/files/readability.js +177 -0
  147. package/risk/files/runs-code.js +615 -0
  148. package/risk/files/script.js +118 -0
  149. package/risk/files/stamps.js +47 -0
  150. package/risk/index.d.ts +254 -0
  151. package/risk/index.js +11 -0
  152. package/risk/npm/assess.js +285 -0
  153. package/risk/npm/combined.js +68 -0
  154. package/risk/npm/compare.js +257 -0
  155. package/risk/npm/dependencies.js +172 -0
  156. package/risk/npm/diff.js +79 -0
  157. package/risk/npm/downloads.js +84 -0
  158. package/risk/npm/files.js +445 -0
  159. package/risk/npm/held.js +63 -0
  160. package/risk/npm/install-scripts.js +34 -0
  161. package/risk/npm/malware.js +36 -0
  162. package/risk/npm/manifest.js +65 -0
  163. package/risk/npm/new-dependency.js +60 -0
  164. package/risk/npm/old-version.js +108 -0
  165. package/risk/npm/registry.js +388 -0
  166. package/risk/npm/release.js +85 -0
  167. package/risk/npm/score.js +59 -0
  168. package/risk/npm/specs.js +124 -0
  169. package/risk/npm/tarball.js +96 -0
  170. package/server-common/database-config.ts +16 -0
  171. package/server-common/initialize.ts +18 -0
  172. package/server-common/npm-advisories.ts +101 -0
  173. package/{server → server-common}/origin.ts +5 -5
  174. package/server-common/reap.ts +48 -0
  175. package/server-common/scan-config.ts +19 -0
  176. package/server-common/standalone.ts +29 -0
  177. package/server-common/storage-crypto.ts +182 -0
  178. package/server-common/storage-log.ts +38 -0
  179. package/server-common/vercel-blob.ts +103 -0
  180. package/server-e2e/app.ts +487 -0
  181. package/{server → server-e2e}/bus-receiver.ts +2 -1
  182. package/{server → server-e2e}/cli.js +9 -4
  183. package/{server → server-e2e}/config.ts +50 -42
  184. package/{server → server-e2e}/db-neon.ts +2 -2
  185. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  186. package/{server → server-e2e}/db-stmt.ts +2 -2
  187. package/{server → server-e2e}/db.ts +7 -7
  188. package/{server → server-e2e}/http.ts +44 -13
  189. package/{server → server-e2e}/hub.ts +40 -9
  190. package/server-e2e/index.ts +17 -0
  191. package/server-e2e/lifecycle.ts +95 -0
  192. package/{server → server-e2e}/neon-driver.ts +2 -2
  193. package/{server → server-e2e}/npm-proxy.ts +12 -145
  194. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  195. package/{server → server-e2e}/objstore/blob.ts +1 -1
  196. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  197. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  198. package/{server → server-e2e}/objstore/init.ts +19 -27
  199. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  200. package/server-e2e/objstore/rest-deny.ts +28 -0
  201. package/server-e2e/objstore/rest-mint.ts +224 -0
  202. package/{server → server-e2e}/objstore/rest.ts +3 -3
  203. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  204. package/{server → server-e2e}/objstore/store.ts +7 -7
  205. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  206. package/{server → server-e2e}/pubsub.ts +12 -9
  207. package/{server → server-e2e}/sign.ts +4 -4
  208. package/{server → server-e2e}/sse-server.ts +60 -33
  209. package/{server → server-e2e}/static.ts +31 -21
  210. package/{server → server-e2e}/sync-handlers.ts +64 -30
  211. package/{server → server-e2e}/ws-server.ts +20 -9
  212. package/server-managed/activity.ts +258 -0
  213. package/server-managed/agent-merges.ts +109 -0
  214. package/server-managed/app-agents.ts +21 -0
  215. package/server-managed/avatar-store.ts +13 -0
  216. package/server-managed/blob-store.ts +17 -0
  217. package/server-managed/brotli.ts +10 -0
  218. package/server-managed/bundle-advisories.ts +120 -0
  219. package/server-managed/bundle-advisory-inventory.ts +77 -0
  220. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  221. package/server-managed/bundle-build-diagnostics.js +23 -0
  222. package/server-managed/bundle-build-leases.ts +41 -0
  223. package/server-managed/bundle-build-worker.js +115 -0
  224. package/server-managed/bundle-build.ts +200 -0
  225. package/server-managed/bundle-cache.ts +211 -0
  226. package/server-managed/bundle-catalog.ts +29 -0
  227. package/server-managed/bundle-commits.ts +140 -0
  228. package/server-managed/bundle-store.ts +24 -0
  229. package/server-managed/bundle-summary-cache.ts +130 -0
  230. package/server-managed/bundle.ts +119 -0
  231. package/server-managed/cache-storage.ts +20 -0
  232. package/server-managed/cli.js +21 -0
  233. package/server-managed/combined.ts +47 -0
  234. package/server-managed/comments.ts +151 -0
  235. package/server-managed/commit-coauthors.ts +106 -0
  236. package/server-managed/commit-lockfiles.ts +73 -0
  237. package/server-managed/comparison.ts +41 -0
  238. package/server-managed/config.ts +164 -0
  239. package/server-managed/content-access.ts +15 -0
  240. package/server-managed/crypto.ts +25 -0
  241. package/server-managed/db-methods.ts +1923 -0
  242. package/server-managed/db-neon.ts +458 -0
  243. package/server-managed/db-schema.ts +238 -0
  244. package/server-managed/db-table-names.ts +22 -0
  245. package/server-managed/db.ts +142 -0
  246. package/server-managed/dependabot-access.ts +60 -0
  247. package/server-managed/dependabot-alerts.ts +185 -0
  248. package/server-managed/dependency-review-comments.ts +100 -0
  249. package/server-managed/dependency-reviews.ts +346 -0
  250. package/server-managed/github-accounts.ts +60 -0
  251. package/server-managed/github-actions.ts +71 -0
  252. package/server-managed/github-app.ts +415 -0
  253. package/server-managed/github-errors.ts +70 -0
  254. package/server-managed/github-graphql.ts +39 -0
  255. package/server-managed/github-issue-links.ts +140 -0
  256. package/server-managed/github-issue-oauth.ts +47 -0
  257. package/server-managed/github-issues.ts +152 -0
  258. package/server-managed/github-link-oauth.ts +69 -0
  259. package/server-managed/github-links.ts +204 -0
  260. package/server-managed/github-metadata.ts +213 -0
  261. package/server-managed/github-oauth.ts +238 -0
  262. package/server-managed/github-pulls.ts +227 -0
  263. package/server-managed/github-requests.ts +39 -0
  264. package/server-managed/github-users.ts +30 -0
  265. package/server-managed/http-response.ts +76 -0
  266. package/server-managed/http.ts +5329 -0
  267. package/server-managed/import-triage.ts +51 -0
  268. package/server-managed/index.ts +192 -0
  269. package/server-managed/kept-briefly.ts +37 -0
  270. package/server-managed/kept.ts +28 -0
  271. package/server-managed/link-reports.ts +162 -0
  272. package/server-managed/locked-packages.ts +129 -0
  273. package/server-managed/lockfile-scope.ts +49 -0
  274. package/server-managed/lru.ts +30 -0
  275. package/server-managed/managed-issues.ts +78 -0
  276. package/server-managed/management-catalog.ts +45 -0
  277. package/server-managed/management.ts +99 -0
  278. package/server-managed/npm-delta-worker.js +23 -0
  279. package/server-managed/npm-delta.ts +194 -0
  280. package/server-managed/npm-insights.ts +449 -0
  281. package/server-managed/npm-kept.ts +215 -0
  282. package/server-managed/npm-loads.ts +264 -0
  283. package/server-managed/npm-manifest.ts +115 -0
  284. package/server-managed/npm-packages.ts +454 -0
  285. package/server-managed/npm-provenance.ts +123 -0
  286. package/server-managed/npm-risk.ts +285 -0
  287. package/server-managed/object-storage-disk.ts +148 -0
  288. package/server-managed/object-storage-vercel.ts +67 -0
  289. package/server-managed/object-storage.ts +75 -0
  290. package/server-managed/package-entry-points.ts +51 -0
  291. package/server-managed/pretty-print.ts +434 -0
  292. package/server-managed/public-workspace.ts +238 -0
  293. package/server-managed/pull-comment-reviews.ts +161 -0
  294. package/server-managed/pull-dependencies.ts +348 -0
  295. package/server-managed/pull-dependency-reviews.ts +328 -0
  296. package/server-managed/pull-reviews.ts +205 -0
  297. package/server-managed/queue.ts +39 -0
  298. package/server-managed/release-commits.ts +84 -0
  299. package/server-managed/rename-diff-worker.js +26 -0
  300. package/server-managed/rename-diff.ts +279 -0
  301. package/server-managed/repo-path.ts +21 -0
  302. package/server-managed/report-migration.ts +35 -0
  303. package/server-managed/report-query.ts +10 -0
  304. package/server-managed/report-response.ts +16 -0
  305. package/server-managed/report-sources.ts +178 -0
  306. package/server-managed/report-store.ts +85 -0
  307. package/server-managed/repository-actions.ts +218 -0
  308. package/server-managed/repository-agents.ts +355 -0
  309. package/server-managed/repository-aliases.ts +91 -0
  310. package/server-managed/repository-browser.ts +294 -0
  311. package/server-managed/repository-changes.ts +284 -0
  312. package/server-managed/repository-code.ts +215 -0
  313. package/server-managed/repository-dependency-reviews.ts +165 -0
  314. package/server-managed/repository-discovery.ts +82 -0
  315. package/server-managed/repository-history.ts +432 -0
  316. package/server-managed/repository-issues.ts +195 -0
  317. package/server-managed/repository-policy.ts +27 -0
  318. package/server-managed/repository-pulls.ts +156 -0
  319. package/server-managed/repository-pulse.ts +460 -0
  320. package/server-managed/repository-risk-assess.ts +105 -0
  321. package/server-managed/repository-risk-audit.ts +79 -0
  322. package/server-managed/repository-risk-read.ts +170 -0
  323. package/server-managed/repository-risk.ts +491 -0
  324. package/server-managed/repository-squashes.ts +168 -0
  325. package/server-managed/request-timing.ts +63 -0
  326. package/server-managed/reviewed-dependencies.ts +150 -0
  327. package/server-managed/revisions.ts +35 -0
  328. package/server-managed/scan-downloads.ts +87 -0
  329. package/server-managed/session.ts +132 -0
  330. package/server-managed/slugs.ts +38 -0
  331. package/server-managed/solidity-entry-points.ts +71 -0
  332. package/server-managed/sql-postgres.ts +34 -0
  333. package/server-managed/sql.ts +88 -0
  334. package/server-managed/static.ts +28 -0
  335. package/server-managed/storage-command.ts +16 -0
  336. package/server-managed/storage-db.ts +183 -0
  337. package/server-managed/storage-encryption.ts +290 -0
  338. package/server-managed/storage-maintenance.ts +259 -0
  339. package/server-managed/storage-payload.ts +121 -0
  340. package/server-managed/storage-stores.ts +91 -0
  341. package/server-managed/storage.ts +82 -0
  342. package/server-managed/team-app.ts +134 -0
  343. package/server-managed/team-catalog.ts +7 -0
  344. package/server-managed/team-feed.ts +180 -0
  345. package/server-managed/team-npm-scopes.ts +106 -0
  346. package/server-managed/team-reports.ts +307 -0
  347. package/server-managed/triage-response.ts +16 -0
  348. package/server-managed/type-only-worker.js +19 -0
  349. package/server-managed/type-only.ts +105 -0
  350. package/server-managed/uploads.ts +106 -0
  351. package/server-managed/upstream-cache.ts +243 -0
  352. package/server-managed/view-as.ts +58 -0
  353. package/server-managed/workspace-shares.ts +169 -0
  354. package/server.ts +70 -0
  355. package/server/index.ts +0 -480
  356. package/server/lifecycle.ts +0 -208
  357. /package/{LICENSE → LICENSE-MIT} +0 -0
  358. /package/{server → server-e2e}/auth.ts +0 -0
  359. /package/{server → server-e2e}/config.example.json +0 -0
  360. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  361. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  362. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  363. /package/{server → server-e2e}/peer.ts +0 -0
  364. /package/{server → server-e2e}/sse-session.ts +0 -0
  365. /package/{server → server-e2e}/util.ts +0 -0
  366. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,215 @@
1
+ // Reading a directory of a repository at a commit for the managed code viewer
2
+ // (GET /api/admin/repositories/code): its tree's tarball as GitHub archives
3
+ // it, verified against the tree's id and kept in upstream's disk cache as
4
+ // bundle builds keep theirs (getRepoTreeTarball), and its files as the npm
5
+ // viewer reads a package's (npm-packages.ts npmFileRows). Readers asking for
6
+ // the same directory meanwhile share its body, a load among the npm viewer's
7
+ // active ones (npm-loads.ts sharedLoad). The default branch's head's bodies
8
+ // are kept in byte storage, for the next to open them (repositoryCodeKept).
9
+ import { Buffer } from 'node:buffer'
10
+ import { createHash } from 'node:crypto'
11
+ import { encodeBrotli } from './brotli.ts'
12
+ import { CacheMissError, type CacheStorage } from './cache-storage.ts'
13
+ import type { KeptStore } from './npm-kept.ts'
14
+ import { plainObject } from './npm-manifest.ts'
15
+ import { MAX_NPM_JSON_LENGTH, NpmPackageError, npmFileRows } from './npm-packages.ts'
16
+ import { type NpmLoad, loadPlaces, readTarballFiles, sharedLoad } from './npm-loads.ts'
17
+
18
+ // What a directory may hold, bounded apart from a package's (npm-packages.ts),
19
+ // as the viewer holds every file in memory: its files' count and bytes.
20
+ export const MAX_REPOSITORY_CODE_FILES = 80_000
21
+ export const MAX_REPOSITORY_CODE_BYTES = 256 * 1024 * 1024
22
+ // The tarball they come in, bounded in proportion as a package's is: its tar
23
+ // stream, headers and padding included, and its entries, as many again as
24
+ // files, as GitHub's has one for each directory too.
25
+ const REPOSITORY_TAR = { maxFiles: MAX_REPOSITORY_CODE_FILES, maxBytes: MAX_REPOSITORY_CODE_BYTES, maxEntries: 2 * MAX_REPOSITORY_CODE_FILES, maxTarBytes: 384 * 1024 * 1024 }
26
+ // What the files may take as the viewer's JSON, written as one string: short
27
+ // of the longest V8 holds (2^29 - 24 characters).
28
+ const MAX_REPOSITORY_CODE_JSON_LENGTH = 384 * 1024 * 1024
29
+
30
+ // Whether a directory's files fit, by GitHub's listing of its tree and every
31
+ // tree under it (repository-browser.ts repositoryTreeFiles): asked before
32
+ // its tarball is, which may run to hundreds of MiB. One GitHub cut short
33
+ // (null) has too many.
34
+ export function repositoryCodeFits(files: RepositoryCodeFiles | null): files is RepositoryCodeFiles {
35
+ return files !== null && files.files <= MAX_REPOSITORY_CODE_FILES && files.bytes <= MAX_REPOSITORY_CODE_BYTES
36
+ }
37
+
38
+ // A directory's files' count and bytes, as GitHub lists its tree.
39
+ export interface RepositoryCodeFiles { files: number; bytes: number }
40
+
41
+ // The directory read: the repository by its id and name, the commit, the
42
+ // directory (`''` for the root), its tree's id, and its files as listed,
43
+ // which weigh its load among the active ones.
44
+ export interface RepositoryCodeInput { repoId: number; repo: string; commit: string; path: string; tree: string; listed: RepositoryCodeFiles }
45
+
46
+ // A package's refusals, as a directory's: past the bounds, or an archive
47
+ // @preventive/archive refuses, as it does one holding a symlink that climbs
48
+ // past the repository's root, or an absolute one.
49
+ const DIRECTORY_ERRORS: Record<string, [number, string]> = { 'package-too-large': [413, 'directory-too-large'], 'bad-tarball': [422, 'directory-unreadable'] }
50
+ const directoryError = (err: unknown) => {
51
+ const as = err instanceof NpmPackageError ? DIRECTORY_ERRORS[err.message] : undefined
52
+ return as ? new NpmPackageError(...as) : err
53
+ }
54
+
55
+ // The directory as the viewer reads it, as brotli-encoded JSON: `{ repoId,
56
+ // repo, commit, path, tree, files }`, its files as npmFileRows gives them,
57
+ // by their paths in the directory, from `tarball`, the tree's, as upstream
58
+ // verifies it with its symlinks let climb `symlinksAbove` levels.
59
+ export function loadRepositoryCodeBody(input: RepositoryCodeInput, tarball: (symlinksAbove: number) => Promise<Uint8Array>): NpmLoad<Buffer> {
60
+ const { repoId, repo, commit, path, tree, listed } = input
61
+ // A symlink may reach the repository's root, as a monorepo package's
62
+ // LICENSE links the root's, and no further: the directory's depth, less
63
+ // the level the archive's root already stands above its top directory.
64
+ const symlinksAbove = path.split('/').length - 1
65
+ // Its places among the active loads, by its files as listed, hold their
66
+ // JSON too, which the listing cannot tell, as escaping may grow a text
67
+ // sixfold: as much as a package's may take for each.
68
+ const places = loadPlaces(listed)
69
+ const maxLength = Math.min(MAX_REPOSITORY_CODE_JSON_LENGTH, places * MAX_NPM_JSON_LENGTH)
70
+ return sharedLoad(`repository ${repoId} ${repo} ${commit} ${path} ${tree}`, async () => {
71
+ let files
72
+ try { files = npmFileRows(await readTarballFiles(await tarball(symlinksAbove), { ...REPOSITORY_TAR, symlinksAbove }), maxLength) }
73
+ catch (err) { throw directoryError(err) }
74
+ return encodeBrotli(Buffer.from(JSON.stringify({ repoId, repo, commit, path, tree, files })))
75
+ }, 'repository-busy', { places })
76
+ }
77
+
78
+ // The form of the bodies kept, bumped as what a body holds changes
79
+ // (npmFileRows): one kept in another is none to read.
80
+ const KEPT_CODE_VERSION = 1
81
+ // The largest body kept. Source text runs, as brotli's JSON, to a third of
82
+ // its bytes or less, and a directory's binary files are named, not held, so
83
+ // this keeps one of some 200 MiB of text: nearly every directory that opens
84
+ // at all (MAX_REPOSITORY_CODE_BYTES). One past it would take as much byte
85
+ // storage as hundreds of ordinary directories, and hold a serverless
86
+ // invocation a while uploading it: it is sent as ever, and read again the
87
+ // next time.
88
+ export const MAX_KEPT_CODE_BYTES = 64 * 1024 * 1024
89
+ const COMMIT = /^[a-f\d]{40}$/u
90
+
91
+ // A directory as kept: the repository by its id and name, the commit and the
92
+ // directory, as its body names them; and the head they are kept at.
93
+ export type KeptCodeAt = Pick<RepositoryCodeInput, 'repoId' | 'repo' | 'commit' | 'path'>
94
+ type KeptCodeHead = Pick<KeptCodeAt, 'commit' | 'repo'>
95
+ // Under the repository, then the commit, so either goes by its prefix; the
96
+ // repository's name and the directory hashed, as a key's parts are names.
97
+ const keptKey = ({ repoId, repo, commit, path }: KeptCodeAt) =>
98
+ `${repoId}/${commit}/v${KEPT_CODE_VERSION}-${createHash('sha256').update(JSON.stringify([repo, path])).digest('hex')}.json.br`
99
+
100
+ // Bodies being kept, by their storage and key, each kept once; and each
101
+ // storage's own name among the active loads, as its reads are one of them.
102
+ const keeping = new WeakMap<CacheStorage, Set<string>>()
103
+ const storageNames = new WeakMap<CacheStorage, number>()
104
+ let storagesNamed = 0
105
+
106
+ // A body kept, read whole, where it is one MAX_KEPT_CODE_BYTES keeps.
107
+ async function readKept(storage: CacheStorage, key: string): Promise<Buffer | null> {
108
+ const { size, stream } = await storage.open(key)
109
+ try {
110
+ if (size !== null && size > MAX_KEPT_CODE_BYTES) return null
111
+ const parts: Buffer[] = []
112
+ let length = 0
113
+ for await (const part of stream) {
114
+ length += (part as Buffer).byteLength
115
+ if (length > MAX_KEPT_CODE_BYTES) return null
116
+ parts.push(Buffer.from(part as Buffer))
117
+ }
118
+ return Buffer.concat(parts, length)
119
+ } finally { stream.destroy() }
120
+ }
121
+
122
+ // The directories of repositories' default branches' heads, as
123
+ // loadRepositoryCodeBody encodes them, kept in the managed byte storage
124
+ // (`storage`: Vercel Blob, else the filesystem, encrypted at rest where
125
+ // storage is: storage-payload.ts REPOSITORY_CODE_CACHE), so that one opened
126
+ // again, on any instance, asks GitHub for no tarball, and is neither unpacked
127
+ // nor encoded again; by the commit `heads` keeps for each repository, the
128
+ // head as last read (npm-kept.ts codeHeadStore). Most opens read the head:
129
+ // `/repo/<repo id>` opens it, and its route then names its commit, as do its
130
+ // directories opened from it and a reload. A read naming the default branch
131
+ // (`''`, or `heads/` and its name) takes its head as GitHub resolves it for
132
+ // the commit kept, the directories kept at any other removed (`own`ed,
133
+ // behind its answer); any other read, of a commit, a tag or another branch,
134
+ // reads and keeps a directory only where what it resolves to is the commit
135
+ // kept. Nothing kept is per reader, and each is read only once the reader's
136
+ // own access has named its commit. A body names what it was read of, so
137
+ // instances disagreeing on the head meanwhile, as at a push, only cost a
138
+ // directory read again: one kept for a head since moved on removes itself.
139
+ // The head is kept with the repository's name, which each body's key holds,
140
+ // so a repository renamed has what was kept under its name before removed
141
+ // as a read next names its head. A head is written only for a repository
142
+ // still `active` after it, as one removed meanwhile has its head removed
143
+ // after (remove). Byte storage, as the database, is an optimization: its
144
+ // failures read as a directory kept nowhere, and keep nothing.
145
+ export function repositoryCodeKept(storage: CacheStorage, heads: KeptStore<KeptCodeHead>, active: (repoId: number) => Promise<boolean>,
146
+ own: (work: Promise<unknown>) => void, debug = false) {
147
+ const warn = (what: string) => (error: unknown) => { if (debug) console.warn(`managed: kept repository code ${what} failed:`, error) }
148
+ const headOf = async (repoId: number): Promise<KeptCodeHead | null> => {
149
+ const kept: unknown = (await heads.read(String(repoId)))?.value
150
+ return plainObject(kept) && typeof kept['commit'] === 'string' && typeof kept['repo'] === 'string' ? { commit: kept['commit'], repo: kept['repo'] } : null
151
+ }
152
+ // What is kept of repository `repoId`: its head first, so that a body being
153
+ // kept meanwhile removes itself.
154
+ const remove = async (repoId: number) => {
155
+ await heads.remove(String(repoId))
156
+ await storage.delete(String(repoId))
157
+ }
158
+ return {
159
+ // Whether a read of `ref` at repository `repoId`, by its name `repo`,
160
+ // resolved to `commit` with `defaultBranch` GitHub's, reads and keeps
161
+ // its directories.
162
+ async at({ repoId, repo, commit }: Omit<KeptCodeAt, 'path'>, ref: string, defaultBranch: string): Promise<boolean> {
163
+ if (!COMMIT.test(commit)) return false
164
+ const kept = await headOf(repoId)
165
+ const atKept = kept?.commit === commit && kept.repo === repo
166
+ if (ref !== '' && (defaultBranch === '' || ref !== `heads/${defaultBranch}`)) return atKept
167
+ if (!atKept) {
168
+ await heads.write(String(repoId), { commit, repo }, Date.now())
169
+ // One removed as its commit was read, its head removed before this
170
+ // wrote it again, keeps nothing, and has what is kept removed again.
171
+ if (!await active(repoId).catch(() => false)) {
172
+ await remove(repoId).catch(warn('removal'))
173
+ return false
174
+ }
175
+ // Those kept at the head before, or, renamed, every one kept under
176
+ // its name before.
177
+ own(storage.delete(String(repoId), kept?.repo === repo ? commit : undefined).catch(warn('removal')))
178
+ }
179
+ return true
180
+ },
181
+ // The body kept of the directory `at` names, or null: a load among the
182
+ // active ones (npm-loads.ts sharedLoad), as one read from GitHub is, a
183
+ // place of them, as it holds no more than a package may
184
+ // (MAX_KEPT_CODE_BYTES), till each reader has released it; readers
185
+ // asking for it meanwhile share it, and none is read where every place
186
+ // is taken (`repository-busy`).
187
+ read(at: KeptCodeAt): NpmLoad<Buffer | null> {
188
+ const key = keptKey(at)
189
+ const name = storageNames.get(storage) ?? storageNames.set(storage, ++storagesNamed).get(storage)!
190
+ return sharedLoad(`repository kept ${name} ${key}`, () => readKept(storage, key).catch((error: unknown) => {
191
+ if (!(error instanceof CacheMissError)) warn('read')(error)
192
+ return null
193
+ }), 'repository-busy')
194
+ },
195
+ // The directory's body kept, behind its answer, where it is no larger
196
+ // than MAX_KEPT_CODE_BYTES; and removed again, with the rest of its
197
+ // commit's, where the head kept is another by then, as when it moved on
198
+ // or the repository was renamed or removed meanwhile.
199
+ keep(at: KeptCodeAt, body: Buffer): void {
200
+ const key = keptKey(at)
201
+ const kept = keeping.get(storage) ?? keeping.set(storage, new Set()).get(storage)!
202
+ if (body.byteLength > MAX_KEPT_CODE_BYTES || kept.has(key)) return
203
+ kept.add(key)
204
+ own((async () => {
205
+ await storage.put(key, body)
206
+ const head = await headOf(at.repoId)
207
+ if (head?.commit !== at.commit || head.repo !== at.repo) await storage.delete(`${at.repoId}/${at.commit}`)
208
+ })().catch(warn('write')).finally(() => kept.delete(key)))
209
+ },
210
+ // What is kept of repository `repoId`, removed once the repository is
211
+ // no longer active.
212
+ remove,
213
+ }
214
+ }
215
+ export type RepositoryCodeKept = ReturnType<typeof repositoryCodeKept>
@@ -0,0 +1,165 @@
1
+ // A connected repository's dependency review, as an admin sets it, and the
2
+ // reviews that pass its pull requests' dependencies (pull-dependency-reviews.ts).
3
+ // Enabled, each package version its open pull requests' lockfiles lock needs
4
+ // as many published reviews as `required`; those its lockfiles locked at
5
+ // `since`, a commit (null for none), none; of the lockfiles it holds to
6
+ // (`lockfiles`, lockfile-scope.ts), every one by default. It is in effect only where a team
7
+ // that reviews dependencies has the repository, which is where reviews that
8
+ // count are published, and not revoked: each reviewer's latest of a version
9
+ // tells where they stand on it, a reject holding it unreviewed whatever
10
+ // others passed it till they accept it again; and a reviewer standing on
11
+ // accepting it passes it by a review of theirs that is full (every file
12
+ // checked; a partial review counts for nothing yet) and accepting (Accept,
13
+ // or Accept with nits).
14
+ import type { ManagedSql } from './sql.ts'
15
+ import { type LockfileScope, scopeText, storedScope } from './lockfile-scope.ts'
16
+ import { reviewsIn } from './dependency-reviews.ts'
17
+ import { type ReviewVerdict, summarizeReview } from '../common/dependency-reviews.js'
18
+
19
+ export const REPOSITORY_DEPENDENCY_REVIEW_COLUMNS = [['dependency_review', 'INTEGER NOT NULL DEFAULT 0'],
20
+ ['dependency_reviews_required', 'INTEGER NOT NULL DEFAULT 1'], ['dependency_review_since', 'TEXT'], ['dependency_review_lockfiles', 'TEXT']] as const
21
+ export const MAX_REQUIRED_REVIEWS = 10
22
+ export const SINCE_COMMIT = /^[a-f\d]{40}$/u
23
+
24
+ export interface RepoDependencyReview { enabled: boolean; required: number; since: string | null; lockfiles: LockfileScope }
25
+ // A review published of the version it is of: its package, version and
26
+ // npm's integrity of it, the patch it is of (none here, the app reviewing no
27
+ // patch yet), the version it was compared with (none for a whole package's),
28
+ // who wrote it (`reviewer`, their id, else their login then), their login
29
+ // as it was then (`login`), its verdict, whether it checked every file
30
+ // (`full`), and when it was published.
31
+ // One made in a pull request's comments (`comment`) counts for it alone;
32
+ // one published here has its `id`.
33
+ export interface PublishedReview {
34
+ id?: string; package: string; version: string; integrity: string; patchHash?: string; basePackage: string | null; baseVersion: string | null
35
+ reviewer: string; login: string; status: ReviewVerdict; full: boolean; time: number; comment?: true
36
+ }
37
+ // A repository's review in effect for a reader: its settings, whether they
38
+ // publish reviews to one of the teams that count (`canReview`), and the
39
+ // published reviews of the packages asked.
40
+ export interface RepoReviewState extends Omit<RepoDependencyReview, 'enabled'> { canReview: boolean; reviews: PublishedReview[] }
41
+
42
+ // A package version whose published reviews are asked.
43
+ export interface ReviewedVersionKey { name: string; version: string }
44
+
45
+ export interface RepositoryDependencyReviewStore {
46
+ listRepoDependencyReviews(): Promise<Map<number, RepoDependencyReview>>
47
+ // One repository's, none where it isn't connected.
48
+ repoDependencyReview(repoId: number): Promise<RepoDependencyReview | null>
49
+ // Those of `repoIds` connected, by their ids, in one statement.
50
+ getRepoDependencyReviews(repoIds: number[]): Promise<Map<number, RepoDependencyReview>>
51
+ // The teams whose published reviews count for each repository (COUNTING_TEAM),
52
+ // by name.
53
+ listRepoReviewTeams(): Promise<Map<number, { id: string; name: string }[]>>
54
+ // Sets a repository's review, its commit and lockfiles too but where
55
+ // `since` or `lockfiles` is undefined, which keeps it; the setting then, or
56
+ // null for no repository.
57
+ setRepoDependencyReview(repoId: number, setting: Omit<RepoDependencyReview, 'since' | 'lockfiles'> & { since?: string | null; lockfiles?: LockfileScope }): Promise<RepoDependencyReview | null>
58
+ // Its review in effect for `user`, as `setting` tells it where read
59
+ // already, with the published reviews of `versions`.
60
+ repoDependencyReviewState(repoId: number, user: string, versions: ReviewedVersionKey[], setting?: RepoDependencyReview | null): Promise<RepoReviewState | null>
61
+ // Which of `users` publish reviews to a team counting for repository
62
+ // `repoId`, whose reviews its pull requests' comments make count.
63
+ listRepoReviewers(repoId: number, users: string[]): Promise<Set<string>>
64
+ }
65
+
66
+ type SettingRow = { repoId: number; enabled: number; required: number; since: string | null; lockfiles: string | null }
67
+ const setting = (row: SettingRow): RepoDependencyReview => ({ enabled: row.enabled === 1, required: row.required, since: row.since, lockfiles: storedScope(row.lockfiles) })
68
+ // Teams whose published reviews count for repository `?`: that review
69
+ // dependencies and have it, not hidden.
70
+ const COUNTING_TEAM = `t.hidden = 0 AND t.dependency_review = 1 AND EXISTS (
71
+ SELECT 1 FROM managed_team_repo tr WHERE tr.team_id = t.id AND tr.repo_id = ?)`
72
+ const VERSIONS_AT_ONCE = 250
73
+
74
+ // The published and unrevoked reviews of `versions` in teams counting for
75
+ // repository `repoId`, each with whether it is full, a couple of hundred
76
+ // versions a query: those of their packages' and versions' found, the
77
+ // versions asked alone kept, their files' reviews read of those alone.
78
+ async function publishedReviews(db: ManagedSql, repoId: number, versions: ReviewedVersionKey[]): Promise<PublishedReview[]> {
79
+ const found: PublishedReview[] = []
80
+ for (let at = 0; at < versions.length; at += VERSIONS_AT_ONCE) {
81
+ const asked = versions.slice(at, at + VERSIONS_AT_ONCE)
82
+ const keys = new Set(asked.map(({ name, version }) => `${name}@${version}`))
83
+ const [names, numbers] = [[...new Set(asked.map(({ name }) => name))], [...new Set(asked.map(({ version }) => version))]]
84
+ const rows = (await db.prepare(`SELECT r.id, r.user_id AS userId, r.user_login AS userLogin, r.package, r.version, r.integrity,
85
+ NULLIF(r.base_package, '') AS basePackage, NULLIF(r.base_version, '') AS baseVersion, r.files, r.status, r.published_at AS publishedAt
86
+ FROM managed_dependency_review r
87
+ WHERE r.ecosystem = 'npm' AND r.published_at IS NOT NULL AND r.revoked_at IS NULL AND r.status IS NOT NULL
88
+ AND r.package IN (${names.map(() => '?').join(',')}) AND r.version IN (${numbers.map(() => '?').join(',')})
89
+ AND EXISTS (SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id
90
+ WHERE rt.review_id = r.id AND ${COUNTING_TEAM})`).all(...names, ...numbers, repoId) as
91
+ { id: string; userId: string | null; userLogin: string; package: string; version: string; integrity: string
92
+ basePackage: string | null; baseVersion: string | null; files: string | null; status: ReviewVerdict; publishedAt: number }[])
93
+ .filter(row => keys.has(`${row.package}@${row.version}`))
94
+ if (rows.length === 0) continue
95
+ const entries = new Map<string, { file: string; status: ReviewVerdict | null }[]>()
96
+ for (const { reviewId, ...entry } of await db.prepare(`SELECT review_id AS reviewId, file, status FROM managed_dependency_file_review
97
+ WHERE review_id IN (${rows.map(() => '?').join(',')})`).all(...rows.map(row => row.id)) as { reviewId: string; file: string; status: ReviewVerdict | null }[]) {
98
+ const of = entries.get(reviewId)
99
+ if (of) of.push(entry)
100
+ else entries.set(reviewId, [entry])
101
+ }
102
+ for (const row of rows) {
103
+ const snapshot = row.files === null ? null : JSON.parse(row.files) as string[] | { paths: string[]; nonTextFiles: string[] }
104
+ // A snapshot of paths alone, before non-text files were told, can't be full.
105
+ const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
106
+ const nonText = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
107
+ const full = summarizeReview(entries.get(row.id) ?? [], files, nonText).coverage === 'full'
108
+ found.push({ id: row.id, package: row.package, version: row.version, integrity: row.integrity, basePackage: row.basePackage, baseVersion: row.baseVersion,
109
+ reviewer: row.userId ?? `login:${row.userLogin.toLowerCase()}`, login: row.userLogin, status: row.status, full, time: Number(row.publishedAt) })
110
+ }
111
+ }
112
+ return found
113
+ }
114
+
115
+ export function repositoryDependencyReviewMethods(db: ManagedSql): RepositoryDependencyReviewStore {
116
+ const columns = `repo_id AS repoId, dependency_review AS enabled, dependency_reviews_required AS required, dependency_review_since AS since,
117
+ dependency_review_lockfiles AS lockfiles`
118
+ const readSetting = async (repoId: number) => {
119
+ const row = await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id = ?`).get(repoId) as SettingRow | undefined
120
+ return row ? setting(row) : null
121
+ }
122
+ return {
123
+ async listRepoDependencyReviews() {
124
+ const rows = await db.prepare(`SELECT ${columns} FROM managed_selected_repo`).all() as SettingRow[]
125
+ return new Map(rows.map(row => [row.repoId, setting(row)]))
126
+ },
127
+ repoDependencyReview: readSetting,
128
+ async getRepoDependencyReviews(repoIds) {
129
+ if (repoIds.length === 0) return new Map()
130
+ const rows = await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id IN (${repoIds.map(() => '?').join(',')})`).all(...repoIds) as SettingRow[]
131
+ return new Map(rows.map(row => [row.repoId, setting(row)]))
132
+ },
133
+ async listRepoReviewTeams() {
134
+ const rows = await db.prepare(`SELECT DISTINCT tr.repo_id AS repoId, t.id AS id, t.name AS name FROM managed_team_repo tr
135
+ JOIN managed_team t ON t.id = tr.team_id WHERE t.hidden = 0 AND t.dependency_review = 1 ORDER BY t.name, t.id`).all() as { repoId: number; id: string; name: string }[]
136
+ const teams = new Map<number, { id: string; name: string }[]>()
137
+ for (const { repoId, id, name } of rows) teams.set(repoId, [...teams.get(repoId) ?? [], { id, name }])
138
+ return teams
139
+ },
140
+ async setRepoDependencyReview(repoId, { enabled, required, since, lockfiles }) {
141
+ const set: [string, unknown][] = [['dependency_review', enabled ? 1 : 0], ['dependency_reviews_required', required],
142
+ ...since === undefined ? [] : [['dependency_review_since', since] as [string, unknown]],
143
+ ...lockfiles === undefined ? [] : [['dependency_review_lockfiles', scopeText(lockfiles)] as [string, unknown]]]
144
+ const { changes } = await db.prepare(`UPDATE managed_selected_repo SET ${set.map(([column]) => `${column} = ?`).join(', ')} WHERE repo_id = ?`)
145
+ .run(...set.map(([, value]) => value), repoId)
146
+ if (Number(changes) === 0) return null
147
+ return setting(await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id = ?`).get(repoId) as SettingRow)
148
+ },
149
+ async listRepoReviewers(repoId, users) {
150
+ if (users.length === 0) return new Set()
151
+ const rows = await db.prepare(`SELECT DISTINCT u.id FROM managed_user u JOIN managed_team t ON t.hidden = 0
152
+ WHERE u.id IN (${users.map(() => '?').join(',')}) AND ${reviewsIn(true)} AND ${COUNTING_TEAM}`).all(...users, repoId) as { id: string }[]
153
+ return new Set(rows.map(row => row.id))
154
+ },
155
+ async repoDependencyReviewState(repoId, user, versions, read) {
156
+ const now = read === undefined ? await readSetting(repoId) : read
157
+ if (!now?.enabled) return null
158
+ if (!await db.prepare(`SELECT 1 FROM managed_team t WHERE ${COUNTING_TEAM}`).get(repoId)) return null
159
+ const canReview = await db.prepare(`SELECT 1 FROM managed_team t JOIN managed_user u ON u.id = ?
160
+ WHERE ${reviewsIn(true)} AND ${COUNTING_TEAM}`).get(user, repoId) != null
161
+ const asked = [...new Map(versions.map(version => [`${version.name}@${version.version}`, version])).values()]
162
+ return { required: now.required, since: now.since, lockfiles: now.lockfiles, canReview, reviews: await publishedReviews(db, repoId, asked) }
163
+ },
164
+ }
165
+ }
@@ -0,0 +1,82 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { ManagedConfig } from './config.ts'
3
+ import { GithubApiError, filterInstalledRepos, listInstalledRepos, listUserRepos } from './github-app.ts'
4
+ import type { ConnectedRepo, RepoListing } from './github-app.ts'
5
+
6
+ const CACHE_MS = 60_000
7
+ const MAX_ENTRIES = 100
8
+ interface Entry {
9
+ expires: number
10
+ pending: boolean
11
+ value: Promise<ConnectedRepo[]>
12
+ }
13
+
14
+ // One directory per HTTP handler. Searches/pages reuse discovery, but selected
15
+ // flags still come from the database on every request. User results are scoped
16
+ // to both the managed identity and its current GitHub credential. This cache is
17
+ // only for discovery; connecting a repo still verifies fresh installation access.
18
+ export class RepositoryDiscovery {
19
+ private readonly cache = new Map<string, Entry>()
20
+ private readonly config: ManagedConfig
21
+ private readonly fetchImpl: typeof fetch
22
+ private readonly now: () => number
23
+
24
+ constructor(config: ManagedConfig, fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args), now: () => number = Date.now) {
25
+ this.config = config
26
+ this.fetchImpl = fetchImpl
27
+ this.now = now
28
+ }
29
+
30
+ private read(key: string, load: () => Promise<ConnectedRepo[]>): Promise<ConnectedRepo[]> {
31
+ const cached = this.cache.get(key)
32
+ if (cached && (cached.pending || cached.expires > this.now())) return cached.value
33
+ this.cache.delete(key)
34
+ while (this.cache.size >= MAX_ENTRIES) this.cache.delete(this.cache.keys().next().value!)
35
+ const entry: Entry = { expires: 0, pending: true, value: Promise.resolve([]) }
36
+ entry.value = load().then(repos => {
37
+ entry.expires = this.now() + CACHE_MS
38
+ entry.pending = false
39
+ return repos
40
+ }, (err: unknown) => {
41
+ if (this.cache.get(key) === entry) this.cache.delete(key)
42
+ throw err
43
+ })
44
+ this.cache.set(key, entry)
45
+ return entry.value
46
+ }
47
+
48
+ async list(scope: 'installed' | 'public', userId: string, token: string | null, showAll = false, refresh = false): Promise<RepoListing> {
49
+ // Invalidate derived user results along with the installation catalogue.
50
+ // An older in-flight read cannot repopulate this map after refresh.
51
+ if (refresh) this.cache.clear()
52
+ const installed = () => this.read('installed', () => listInstalledRepos(this.config, this.fetchImpl))
53
+ if (scope === 'installed' && showAll) return { repositories: await installed(), tokenMissing: false }
54
+ const publicInstalled = async (): Promise<ConnectedRepo[]> => (await installed()).filter(repo => repo.visibility === 'public')
55
+ if (token == null) return { repositories: scope === 'installed' ? await publicInstalled() : [], tokenMissing: true }
56
+ const credential = createHash('sha256').update(token).digest('hex')
57
+ const key = JSON.stringify([scope, userId, credential])
58
+ try {
59
+ if (scope === 'installed') {
60
+ const repositories = await this.read(key, async () => filterInstalledRepos(this.config, await installed(), token, this.fetchImpl))
61
+ return { repositories, tokenMissing: false }
62
+ }
63
+ // Installations only deduplicate the public picker. Their failure must
64
+ // not block otherwise readable public repos or look like a stale login.
65
+ // Cache the two sources separately so failed installation discovery is
66
+ // retried without caching a degraded combined list for another minute.
67
+ const [userRepos, installedRepos] = await Promise.all([
68
+ this.read(key, () => listUserRepos(token)),
69
+ installed().catch((err: unknown) => {
70
+ console.warn('managed: installed repo deduplication failed:', err)
71
+ return []
72
+ }),
73
+ ])
74
+ const installedIds = new Set(installedRepos.map(repo => repo.id))
75
+ const repositories = userRepos.filter(repo => repo.visibility === 'public' && !installedIds.has(repo.id))
76
+ return { repositories, tokenMissing: false }
77
+ } catch (err) {
78
+ if (!(err instanceof GithubApiError) || err.status !== 401) throw err
79
+ return { repositories: scope === 'installed' ? await publicInstalled() : [], tokenMissing: true }
80
+ }
81
+ }
82
+ }