@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,294 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { ManagedConfig } from './config.ts'
3
+ import type { SelectedRepo } from './db.ts'
4
+ import { GithubApiError, type GithubIdentity, READ_PERMISSIONS, githubJson, githubRepoPermission, githubUserIdentity, onTokenRejected, repoAccessToken } from './github-app.ts'
5
+ import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
6
+ import { readSolidityEntryPoints } from './solidity-entry-points.ts'
7
+ import { parseGithubCommit } from './bundle-commits.ts'
8
+ import { appDependabotAlerts } from './dependabot-access.ts'
9
+
10
+ const REFS_PAGE = 100
11
+
12
+ // Each branch or tag GitHub lists, by its name and commit, those it names
13
+ // one for.
14
+ const refCommits = (items: { name?: unknown; commit?: { sha?: unknown } }[]) => items.flatMap(item => typeof item?.name === 'string' && item.name
15
+ && typeof item.commit?.sha === 'string' && /^[a-f\d]{40}$/u.test(item.commit.sha) ? [{ name: item.name, sha: item.commit.sha }] : [])
16
+
17
+ // Each branch GitHub lists, by its name and commit. Branches are listed by
18
+ // name, a full page of them leaving the default branch out where many come
19
+ // before it: its commit is read apart then, by its ref, none where that fails.
20
+ async function branchCommits(read: (suffix: string) => Promise<unknown>, branches: { name?: unknown; commit?: { sha?: unknown } }[], defaultBranch: string) {
21
+ const listed = refCommits(branches)
22
+ if (!defaultBranch || branches.length < REFS_PAGE || listed.some(item => item.name === defaultBranch)) return listed
23
+ const ref = await read(`/git/ref/heads/${defaultBranch.split('/').map(encodeURIComponent).join('/')}`).catch(() => null) as { object?: { sha?: unknown } } | null
24
+ return typeof ref?.object?.sha === 'string' && /^[a-f\d]{40}$/u.test(ref.object.sha) ? [...listed, { name: defaultBranch, sha: ref.object.sha }] : listed
25
+ }
26
+
27
+ export interface RepositoryEntry { name: string; path: string; type: 'dir' | 'file' | 'symlink' | 'submodule' }
28
+
29
+ // Restricted managers can walk the ancestors of their grants, without seeing
30
+ // sibling names or files outside those grants. null means the entire repo.
31
+ export function scopedDirectory(path: string, scopes: (string | null)[]): RepositoryEntry[] | null {
32
+ if (scopes.some(scope => !scope || path === scope || path.startsWith(scope + '/'))) return null
33
+ const prefix = path ? path + '/' : ''
34
+ const names = new Set(scopes.filter((scope): scope is string => !!scope && scope.startsWith(prefix)).map(scope => scope.slice(prefix.length).split('/')[0]!))
35
+ return [...names].toSorted().map(name => ({ name, path: prefix + name, type: 'dir' }))
36
+ }
37
+
38
+ export interface RepositoryBrowserUser { githubUserId: number | null; token: string | null }
39
+
40
+ function credentialFailure(err: unknown): err is GithubApiError {
41
+ return err instanceof GithubApiError && ([401, 404].includes(err.status) || err.message === 'github-status-403')
42
+ }
43
+
44
+ // GitHub's answer on a user's access to a repository, as checkAccess reads it:
45
+ // the repository's metadata, which credential reads it (the user's token, the
46
+ // App installation's, or none, as for a public repository read anonymously)
47
+ // and the permission level GitHub told, where it was asked. Kept per user, by
48
+ // their GitHub id and token, and per repository, by its id, name and
49
+ // installation, for config.repositoryAccessCacheMs; only access granted is kept.
50
+ // One answered for a user with a token is kept no longer once GitHub rejects
51
+ // that token (`userToken`, hashed), whichever credential reads it: a private
52
+ // repository's was granted on who that token told GitHub they are. The next
53
+ // check asks GitHub, falling back to the installation's or none as it does.
54
+ type RepoMetadata = { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; default_branch?: unknown }
55
+ type AccessVerdict = { until: number; data: RepoMetadata; credential: 'user' | 'app' | 'none'; level: string | null; userToken: string | null }
56
+ type VerdictCache = { get(): AccessVerdict | undefined; set(verdict: Omit<AccessVerdict, 'until' | 'userToken'>): void }
57
+ const MAX_ACCESS_VERDICTS = 1000
58
+ // By the fetch reading GitHub, as installation tokens are: a test's own.
59
+ const accessVerdicts = new WeakMap<typeof fetch, Map<string, AccessVerdict>>()
60
+ const hashed = (token: string) => createHash('sha256').update(token).digest('hex')
61
+ onTokenRejected((token, fetchImpl) => {
62
+ const kept = accessVerdicts.get(fetchImpl)
63
+ if (!kept) return
64
+ const rejected = hashed(token)
65
+ for (const [key, verdict] of kept) if (verdict.userToken === rejected) kept.delete(key)
66
+ })
67
+
68
+ function verdictCache(config: ManagedConfig, user: RepositoryBrowserUser, repo: SelectedRepo, fetchImpl: typeof fetch): VerdictCache | null {
69
+ const keepMs = config.repositoryAccessCacheMs
70
+ if (!(keepMs > 0)) return null
71
+ let verdicts = accessVerdicts.get(fetchImpl)
72
+ if (!verdicts) { verdicts = new Map(); accessVerdicts.set(fetchImpl, verdicts) }
73
+ const kept = verdicts
74
+ // The token hashed with the rest, never kept as it is.
75
+ const key = createHash('sha256').update(JSON.stringify([user.githubUserId, user.token, repo.repoId, repo.fullName.toLowerCase(), repo.installationId, config.githubAppId])).digest('hex')
76
+ return {
77
+ get() {
78
+ const verdict = kept.get(key)
79
+ if (verdict && verdict.until > Date.now()) return verdict
80
+ kept.delete(key)
81
+ return undefined
82
+ },
83
+ set(verdict) {
84
+ kept.delete(key)
85
+ kept.set(key, { ...verdict, until: Date.now() + keepMs, userToken: user.token ? hashed(user.token) : null })
86
+ while (kept.size > MAX_ACCESS_VERDICTS) kept.delete(kept.keys().next().value!)
87
+ },
88
+ }
89
+ }
90
+
91
+ // Create once per HTTP request. Coalesce concurrent credential lookups, and
92
+ // keep GitHub's answer on each user's access to each repository a while
93
+ // (verdictCache), never sharing credentials or answers across users.
94
+ export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryBrowserUser, fetchImpl: typeof fetch = globalThis.fetch) {
95
+ const tokens = new Map<number, Promise<string | null>>()
96
+ let identity: Promise<GithubIdentity> | undefined
97
+ // The user as GitHub tells them, asked once a request.
98
+ const getIdentity = (): Promise<GithubIdentity> => {
99
+ if (!user.token || user.githubUserId == null) return Promise.reject(new GithubApiError(404, 'no-repository'))
100
+ if (!identity) {
101
+ identity = githubUserIdentity(user.token, fetchImpl).then(value => {
102
+ if (value.id !== user.githubUserId) throw new GithubApiError(404, 'no-repository')
103
+ return value
104
+ })
105
+ }
106
+ return identity
107
+ }
108
+ return {
109
+ reader(repo: SelectedRepo) {
110
+ const installationToken = () => {
111
+ if (repo.installationId == null) return Promise.resolve(null)
112
+ const cached = tokens.get(repo.installationId)
113
+ const token = cached ?? repoAccessToken(config, repo.installationId, fetchImpl).catch((err: unknown) => {
114
+ if (!credentialFailure(err)) throw err
115
+ // Stale installations cannot prevent verified public access.
116
+ return null
117
+ })
118
+ if (!cached) tokens.set(repo.installationId, token)
119
+ return token
120
+ }
121
+ // Its Dependabot alerts, with the token of the installation GitHub tells
122
+ // covers it, where the user's own access lets them see them
123
+ // (dependabot-access.ts).
124
+ return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl, verdictCache(config, user, repo, fetchImpl)).then(reader => ({ ...reader,
125
+ dependabotAlerts: () => appDependabotAlerts(config, repo, { identity: getIdentity, permission: reader.permission }, fetchImpl) }))
126
+ },
127
+ }
128
+ }
129
+
130
+ // The files of the tree `tree` (its id) of the repository `fullName`, and of
131
+ // every tree under it, as GitHub lists them at once with `token`, a reader's
132
+ // (readToken): how many and their bytes, or null where GitHub cuts the
133
+ // listing short, as it does past 100,000 entries.
134
+ export async function repositoryTreeFiles(fullName: string, token: string | null, tree: string, fetchImpl: typeof fetch = globalThis.fetch) {
135
+ if (!/^[a-f\d]{40}$/u.test(tree)) throw new GithubApiError(400, 'bad-tree')
136
+ const url = `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}/git/trees/${tree}?recursive=1`
137
+ const data = await githubJson(url, token, fetchImpl) as { tree?: unknown; truncated?: unknown }
138
+ if (!Array.isArray(data?.tree)) throw new GithubApiError(502, 'github-malformed')
139
+ if (data.truncated === true) return null
140
+ let bytes = 0, files = 0
141
+ for (const item of data.tree as { type?: unknown; size?: unknown }[]) {
142
+ if (item?.type !== 'blob') continue
143
+ files++
144
+ if (typeof item.size === 'number' && item.size > 0) bytes += item.size
145
+ }
146
+ return { files, bytes }
147
+ }
148
+
149
+ type Access = { data: RepoMetadata; token: string | null; level: string | null }
150
+
151
+ // GitHub asked whether the user may read `repo`: its metadata, read with
152
+ // `token` (the reader's credential so far), else the installation's, else
153
+ // none; and of one not public, the user's permission, read with the
154
+ // installation's. What it told, the token reading it, and the level asked.
155
+ async function askAccess(repo: SelectedRepo, start: string | null, userToken: string | null, installationToken: () => Promise<string | null>,
156
+ getIdentity: () => Promise<GithubIdentity>, fetchImpl: typeof fetch): Promise<Access> {
157
+ const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
158
+ let token = start
159
+ const metadata = async () => {
160
+ try { return await githubJson(base, token, fetchImpl) }
161
+ catch (err) {
162
+ if (!token || !credentialFailure(err)) throw err
163
+ const fallback = await installationToken()
164
+ if (fallback && fallback !== token) {
165
+ token = fallback
166
+ try { return await githubJson(base, token, fetchImpl) } catch (fallbackError) { if (!credentialFailure(fallbackError)) throw fallbackError }
167
+ }
168
+ // Anonymous fallback is only for unavailable credentials, never rate
169
+ // limits, network errors, or GitHub outages.
170
+ token = null
171
+ return githubJson(base, token, fetchImpl)
172
+ }
173
+ }
174
+ // A repository last known private asks who the user is, and mints the
175
+ // installation token its permission is asked with, as it asks for its
176
+ // metadata, not after. Each is asked once a request (the token once for
177
+ // concurrent requests too, github-app.ts), so a failure is answered where
178
+ // it is awaited. One made public since mints a token it never waits for,
179
+ // kept for later requests.
180
+ if (repo.private && userToken) {
181
+ getIdentity().catch(() => {})
182
+ if (repo.installationId != null) installationToken().catch(() => {})
183
+ }
184
+ // Stored visibility can be stale (or internal with private=false). Require
185
+ // current, explicit public visibility or the caller's effective permission.
186
+ const data = await metadata() as RepoMetadata
187
+ if (data?.id !== repo.repoId || typeof data.full_name !== 'string' || data.full_name.toLowerCase() !== repo.fullName.toLowerCase()) {
188
+ throw new GithubApiError(404, 'no-repository')
189
+ }
190
+ if (data.private === false && data.visibility === 'public') return { data, token, level: null }
191
+ if (!token) throw new GithubApiError(404, 'no-repository')
192
+ const appToken = await installationToken()
193
+ if (!appToken) throw new GithubApiError(404, 'no-repository')
194
+ const level = await githubRepoPermission(repo.fullName, appToken, await getIdentity(), fetchImpl)
195
+ if (!READ_PERMISSIONS.has(level)) throw new GithubApiError(404, 'no-repository')
196
+ return { data, token: appToken, level }
197
+ }
198
+
199
+ // GitHub's answer as kept (verdictCache), with the credential it chose,
200
+ // where that credential is still had; else asked (`ask`), and kept where it
201
+ // grants access.
202
+ async function keptAccess(verdicts: VerdictCache | null, userToken: string | null, installationToken: () => Promise<string | null>,
203
+ ask: () => Promise<Access>): Promise<Access> {
204
+ const kept = verdicts?.get()
205
+ if (kept) {
206
+ const credential = kept.credential === 'user' ? userToken : kept.credential === 'app' ? await installationToken() : null
207
+ if (kept.credential === 'none' || credential) return { data: kept.data, token: credential, level: kept.level }
208
+ }
209
+ const asked = await ask()
210
+ const { id, full_name, private: hidden, visibility, default_branch } = asked.data
211
+ const data = { id, full_name, private: hidden, visibility, default_branch }
212
+ verdicts?.set({ data, credential: asked.token === null ? 'none' : asked.token === userToken ? 'user' : 'app', level: asked.level })
213
+ return { ...asked, data }
214
+ }
215
+
216
+ async function repositoryReader(repo: SelectedRepo, userToken: string | null, installationToken: () => Promise<string | null>, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch,
217
+ verdicts: VerdictCache | null) {
218
+ const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
219
+ // Public reads can use the signed-in user's quota without repository grants.
220
+ // Nonpublic reads still require the repository App and the effective-user gate,
221
+ // whose permission level is kept (`level`).
222
+ let level: string | null = null, token = userToken ?? await installationToken()
223
+ const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
224
+ const checkAccess = async (): Promise<RepoMetadata> => {
225
+ const access = await keptAccess(verdicts, userToken, installationToken, () => askAccess(repo, token, userToken, installationToken, getIdentity, fetchImpl))
226
+ token = access.token
227
+ level = access.level
228
+ return access.data
229
+ }
230
+ const current = await checkAccess()
231
+ const defaultBranch = typeof current.default_branch === 'string' ? current.default_branch : ''
232
+ return {
233
+ // Its visibility and the user's permission asked again, the user as
234
+ // GitHub told them earlier in the request, not asked again.
235
+ recheckAccess: () => checkAccess(),
236
+ // Server-only credential, selected by the same live visibility/permission
237
+ // gate as directory browsing. Never included in an HTTP response.
238
+ readToken: () => token,
239
+ // The user's permission level on it where the gate asked GitHub of it, as
240
+ // for a private repository; null for a public one.
241
+ permission: () => level,
242
+ // Its default branch as GitHub named it when access was first checked,
243
+ // `''` where it named none: the one whose head `commit('')` reads.
244
+ defaultBranch,
245
+ async refs() {
246
+ // Suggestions are bounded; the input also accepts any branch or tag name.
247
+ const list = async (kind: string) => {
248
+ const data = await read(`/${kind}?per_page=${REFS_PAGE}`)
249
+ if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
250
+ return data
251
+ }
252
+ const [branches, tags] = await Promise.all([list('branches'), list('tags')])
253
+ const names = (items: { name?: unknown }[]) => items.flatMap(item => typeof item?.name === 'string' ? [item.name] : [])
254
+ // Each branch's and tag's commit, for the code viewer's header to name a
255
+ // commit by, and each tag's for the tag cache. A full page may leave
256
+ // tags out; only a shorter one lists every tag.
257
+ return { defaultBranch, branches: names(branches), tags: names(tags), branchCommits: await branchCommits(read, branches, defaultBranch),
258
+ tagCommits: refCommits(tags), tagsComplete: tags.length < REFS_PAGE }
259
+ },
260
+ async commit(ref: string) {
261
+ if (/^[a-f\d]{40}$/iu.test(ref)) return ref
262
+ const data = await read(`/commits/${encodeURIComponent(ref || (defaultBranch ? `heads/${defaultBranch}` : 'HEAD'))}`) as { sha?: unknown }
263
+ if (typeof data?.sha !== 'string' || !/^[a-f\d]{40}$/iu.test(data.sha)) throw new GithubApiError(502, 'github-malformed')
264
+ return data.sha
265
+ },
266
+ // The first commit a listing from `sha` returns is that commit, without
267
+ // the files GET /commits/:sha adds.
268
+ async commitDetails(sha: string) {
269
+ return /^[a-f\d]{40}$/u.test(sha) ? parseGithubCommit(sha, await read(`/commits?sha=${sha}&per_page=1`)) : null
270
+ },
271
+ async directory(path: string, commit: string) {
272
+ const data = await read(`/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(commit)}`)
273
+ if (!Array.isArray(data)) throw new GithubApiError(400, 'not-a-directory')
274
+ const entries: RepositoryEntry[] = []
275
+ for (const item of data) {
276
+ if (typeof item?.name !== 'string' || item.name.includes('/') || item.name === '.' || item.name === '..') continue
277
+ const expectedPath = path ? `${path}/${item.name}` : item.name
278
+ if (item.path !== expectedPath) continue
279
+ const type = item.submodule_git_url ? 'submodule' : item.type
280
+ if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
281
+ }
282
+ const manifestPath = path ? `${path}/package.json` : 'package.json'
283
+ const manifest = data.find(item => item?.path === manifestPath && item.name === 'package.json' && item.type === 'file' && !item.submodule_git_url
284
+ && typeof item.size === 'number' && item.size >= 0 && item.size <= MAX_PACKAGE_BYTES && typeof item.sha === 'string' && /^[a-f\d]{40}$/iu.test(item.sha))
285
+ // Both optional suggestions, read together: neither fails, each
286
+ // answering none where its reads do.
287
+ const [packageEntryPoints, solidity] = await Promise.all([manifest ? readPackageEntryPoints(manifest.sha, path, read) : undefined,
288
+ readSolidityEntryPoints(path, data, read)])
289
+ return { entries, limited: data.length >= 1000, ...(packageEntryPoints ? { packageEntryPoints } : {}),
290
+ ...(solidity.paths.length > 0 || solidity.limited ? { solidityEntryPoints: solidity.paths, soliditySuggestionsLimited: solidity.limited } : {}),
291
+ }
292
+ },
293
+ }
294
+ }
@@ -0,0 +1,284 @@
1
+ // The files each commit changes, as the code viewer's Replay plays them
2
+ // (GET /api/admin/repositories/changes), as `git log --name-status` lists
3
+ // them: each file's path and whether the commit added (`A`), modified (`M`),
4
+ // removed (`D`) or renamed (`R`, with the path it had) it, as GitHub's REST
5
+ // API lists a commit's files (GET /repos/:owner/:repo/commits/:sha), a
6
+ // page of FILES_PAGE at a time, as many as MAX_FILES, `more` where it had
7
+ // more. What a commit changes never changes, so each is kept by the commit
8
+ // (`kept`), whole, read of GitHub once; what a reader is answered, only the
9
+ // files within the directory they asked of (changesWithin). And the files a
10
+ // commit holds, which Replay starts from (GET /api/admin/repositories/tree),
11
+ // as GitHub lists its tree at once (repositoryTree), kept as well.
12
+
13
+ import { GithubApiError, githubJson } from './github-app.ts'
14
+
15
+ // Commits an answer tells at most, read of GitHub as many at once as
16
+ // CHANGES_PARALLEL.
17
+ export const CHANGES_ASKED = 50
18
+ const CHANGES_PARALLEL = 6
19
+ // A commit's files read, as many as fill MAX_FILES, FILES_PAGE a page (the
20
+ // most GitHub lists a page; it lists fewer, unasked).
21
+ export const MAX_FILES = 300
22
+ export const FILES_PAGE = 100
23
+ const MAX_PATH = 4096
24
+ const SHA = /^[a-f\d]{40}$/u
25
+
26
+ export type ChangeStatus = 'A' | 'M' | 'D' | 'R'
27
+ // A file a commit changes: its path, its status, and a rename's path before.
28
+ export type ChangedFile = [path: string, status: Exclude<ChangeStatus, 'R'>] | [path: string, status: 'R', from: string]
29
+ export interface CommitChanges { files: ChangedFile[]; more: boolean }
30
+
31
+ // What each commit changes kept, by the commit.
32
+ export interface ChangesStore {
33
+ read(shas: string[]): Promise<Map<string, CommitChanges>>
34
+ write(sha: string, changes: CommitChanges): Promise<void>
35
+ }
36
+
37
+ const pathOf = (value: unknown) => typeof value === 'string' && value !== '' && value.length <= MAX_PATH && !/\p{Cc}/u.test(value) ? value : null
38
+
39
+ // A file's status as git names it: a copy is a file added; one GitHub
40
+ // lists unchanged (its mode alone changed, say) changes none.
41
+ const STATUSES: Record<string, ChangeStatus> = { added: 'A', copied: 'A', modified: 'M', changed: 'M', removed: 'D', renamed: 'R' }
42
+
43
+ type GithubFile = { filename?: unknown; status?: unknown; previous_filename?: unknown }
44
+
45
+ // A commit's files as GitHub lists them; a rename it names no path before
46
+ // for, a file added.
47
+ export function commitChanges(data: unknown): CommitChanges {
48
+ const listed = (data as { files?: unknown } | null)?.files
49
+ if (!Array.isArray(listed)) throw new GithubApiError(502, 'github-malformed')
50
+ const files = (listed as GithubFile[]).slice(0, MAX_FILES).flatMap((file): ChangedFile[] => {
51
+ const path = pathOf(file?.filename)
52
+ const status = typeof file?.status === 'string' ? STATUSES[file.status] : undefined
53
+ if (!path || !status) return []
54
+ const from = status === 'R' ? pathOf(file.previous_filename) : null
55
+ return [status === 'R' ? from ? [path, 'R', from] : [path, 'A'] : [path, status]]
56
+ })
57
+ return { files, more: listed.length >= MAX_FILES }
58
+ }
59
+
60
+ // What a commit changes within the directory `path` ('' the whole
61
+ // repository): its files there, a rename across its edge the file added
62
+ // where it came in, removed where it went out.
63
+ export function changesWithin({ files, more }: CommitChanges, path: string): CommitChanges {
64
+ if (path === '') return { files, more }
65
+ const within = (file: string) => file.startsWith(`${path}/`)
66
+ return { more, files: files.flatMap((file): ChangedFile[] => {
67
+ if (file[1] !== 'R') return within(file[0]) ? [file] : []
68
+ const [to, , from] = file
69
+ if (within(to)) return [within(from) ? file : [to, 'A']]
70
+ return within(from) ? [[from, 'D']] : []
71
+ }) }
72
+ }
73
+
74
+ // Runs `run` for each of `items`, as many at once as `parallel`.
75
+ async function eachOf<T>(items: T[], parallel: number, run: (item: T) => Promise<void>) {
76
+ let next = 0
77
+ await Promise.all(Array.from({ length: Math.min(parallel, items.length) }, async () => { while (next < items.length) await run(items[next++]!) }))
78
+ }
79
+
80
+ // A commit's files as GitHub lists them, read a page at a time while each
81
+ // is full, till MAX_FILES are.
82
+ async function commitFiles(url: string, token: string | null, fetchImpl: typeof fetch) {
83
+ const files: unknown[] = []
84
+ for (let page = 1; files.length < MAX_FILES; page++) {
85
+ const data = await githubJson(`${url}?per_page=${FILES_PAGE}&page=${page}`, token, fetchImpl) as { files?: unknown } | null
86
+ if (!Array.isArray(data?.files)) throw new GithubApiError(502, 'github-malformed')
87
+ files.push(...data.files)
88
+ if (data.files.length < FILES_PAGE) break
89
+ }
90
+ return { files }
91
+ }
92
+
93
+ // What each of `shas` changes, kept, else read of GitHub and then kept; null
94
+ // for one GitHub has no commit of (as one of another repository is).
95
+ export async function repositoryChanges(fullName: string, token: string | null, shas: string[], kept: ChangesStore,
96
+ fetchImpl: typeof fetch = globalThis.fetch): Promise<Map<string, CommitChanges | null>> {
97
+ const asked = [...new Set(shas)].filter(sha => SHA.test(sha))
98
+ const found = new Map<string, CommitChanges | null>(await kept.read(asked))
99
+ const repo = `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
100
+ await eachOf(asked.filter(sha => !found.has(sha)), CHANGES_PARALLEL, async sha => {
101
+ try {
102
+ const changes = commitChanges(await commitFiles(`${repo}/commits/${sha}`, token, fetchImpl))
103
+ found.set(sha, changes)
104
+ await kept.write(sha, changes)
105
+ } catch (err) {
106
+ // No such commit: GitHub's 404, or its 422 for an id naming none.
107
+ if (err instanceof GithubApiError && (err.message === 'github-not-found' || err.message === 'github-status-422')) found.set(sha, null)
108
+ else throw err
109
+ }
110
+ })
111
+ return new Map(asked.map(sha => [sha, found.get(sha) ?? null]))
112
+ }
113
+
114
+ // The files a commit (or a tree) holds, as many as MAX_TREE_FILES, each by its path and
115
+ // its bytes (`sizes`, in the same order), as
116
+ // GitHub lists its tree and every tree under it at once
117
+ // (GET /repos/:owner/:repo/git/trees/:tree?recursive=1); where GitHub cuts
118
+ // that listing short (past 100,000 entries or 7 MB, as a large repository's
119
+ // is), as GitHub advises: the tree's own entries listed alone, and each tree
120
+ // in it read so in turn, as many at once as TREES_PARALLEL (treeByTrees).
121
+ // Its blobs, not its directories nor submodules, sorted by path; past
122
+ // MAX_TREE_FILES those nearest its root of those read, `cut` where some are
123
+ // left out: more files than MAX_TREE_FILES, a tree's own entries cut short,
124
+ // or more trees to read than MAX_TREE_READS.
125
+ // Null for a commit GitHub has none of. Kept by the commit and the rules it
126
+ // is read by (TREES_READ), a tree kept by older ones (without sizes, the
127
+ // last) read again.
128
+ export const MAX_TREE_FILES = 75_000
129
+ export const TREES_READ = 3
130
+ const MAX_TREE_READS = 400
131
+ const TREES_PARALLEL = 6
132
+ export interface CommitTree { files: string[]; sizes: number[]; cut: boolean }
133
+ export interface TreeStore {
134
+ read(commit: string): Promise<CommitTree | null>
135
+ write(commit: string, tree: CommitTree): Promise<void>
136
+ }
137
+
138
+ type TreeEntry = { type?: unknown; path?: unknown; sha?: unknown; size?: unknown }
139
+ // A file read: its path and its bytes.
140
+ type Blob = [path: string, size: number]
141
+ type TreeListing = (tree: string, recursive: boolean) => Promise<{ entries: TreeEntry[]; truncated: boolean }>
142
+
143
+ export async function repositoryTree(fullName: string, token: string | null, commit: string, kept: TreeStore,
144
+ fetchImpl: typeof fetch = globalThis.fetch): Promise<CommitTree | null> {
145
+ if (!SHA.test(commit)) return null
146
+ const held = await kept.read(commit)
147
+ if (held) return held
148
+ const repo = `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
149
+ const listing: TreeListing = async (tree, recursive) => {
150
+ const data = await githubJson(`${repo}/git/trees/${tree}${recursive ? '?recursive=1' : ''}`, token, fetchImpl) as { tree?: unknown; truncated?: unknown }
151
+ if (!Array.isArray(data?.tree)) throw new GithubApiError(502, 'github-malformed')
152
+ return { entries: data.tree as TreeEntry[], truncated: data.truncated === true }
153
+ }
154
+ let whole: Awaited<ReturnType<TreeListing>>
155
+ try {
156
+ whole = await listing(commit, true)
157
+ } catch (err) {
158
+ if (err instanceof GithubApiError && (err.message === 'github-not-found' || err.message === 'github-status-422')) return null
159
+ throw err
160
+ }
161
+ const files: Blob[] = []
162
+ let cut = false
163
+ if (whole.truncated) cut = await treeByTrees(commit, listing, files)
164
+ else blobsOf(whole.entries, '', files)
165
+ const depth = (path: string) => path.split('/').length
166
+ const byPath = ([a]: Blob, [b]: Blob) => a < b ? -1 : a > b ? 1 : 0
167
+ const nearest = files.length > MAX_TREE_FILES ? files.toSorted((a, b) => depth(a[0]) - depth(b[0]) || byPath(a, b)).slice(0, MAX_TREE_FILES) : files
168
+ const sorted = nearest.toSorted(byPath)
169
+ const tree = { files: sorted.map(([path]) => path), sizes: sorted.map(([, size]) => size), cut: cut || files.length > MAX_TREE_FILES }
170
+ await kept.write(commit, tree)
171
+ return tree
172
+ }
173
+
174
+ // The blobs of a tree's entries, each its path under `prefix` and its bytes
175
+ // (none, as GitHub names none), onto `files`.
176
+ function blobsOf(entries: TreeEntry[], prefix: string, files: Blob[]) {
177
+ for (const item of entries) {
178
+ if (item?.type !== 'blob' || !pathOf(item.path)) continue
179
+ files.push([`${prefix}${item.path as string}`, Number.isSafeInteger(item.size) && (item.size as number) > 0 ? item.size as number : 0])
180
+ }
181
+ }
182
+
183
+ // The files under the commit's tree, `commit`, onto `files`, its trees read
184
+ // a round at a time, those nearest the root first: its own entries, then
185
+ // each tree in it whole, or, where that is cut short, alone in turn, and so
186
+ // on. Each round read whole, whatever answers first, but for trees no file
187
+ // of which could be kept: as deep as, or deeper than, the files nearest the
188
+ // root, as many as MAX_TREE_FILES, of those read (files of a tree are at
189
+ // least a level deeper than it is). As many trees read in all as
190
+ // MAX_TREE_READS, those of a round first in its order. Whether some were
191
+ // left out.
192
+ async function treeByTrees(commit: string, listing: TreeListing, files: Blob[]): Promise<boolean> {
193
+ let cut = false, reads = 1, round = [{ tree: commit, prefix: '', recursive: false }]
194
+ const atDepth: number[] = []
195
+ let counted = 0
196
+ while (round.length > 0) {
197
+ for (; counted < files.length; counted++) {
198
+ const depth = files[counted]![0].split('/').length
199
+ atDepth[depth] = (atDepth[depth] ?? 0) + 1
200
+ }
201
+ // The depth no file deeper than is kept.
202
+ let kept = Number.POSITIVE_INFINITY
203
+ for (let depth = 0, sum = 0; depth < atDepth.length; depth++) {
204
+ sum += atDepth[depth] ?? 0
205
+ if (sum >= MAX_TREE_FILES) {
206
+ kept = depth
207
+ break
208
+ }
209
+ }
210
+ const asked = round.filter(({ prefix }) => prefix.split('/').length - 1 < kept).slice(0, Math.max(0, MAX_TREE_READS - reads))
211
+ cut ||= asked.length < round.length
212
+ reads += asked.length
213
+ const listed: Awaited<ReturnType<TreeListing>>[] = []
214
+ await eachOf([...asked.keys()], TREES_PARALLEL, async at => { listed[at] = await listing(asked[at]!.tree, asked[at]!.recursive) })
215
+ const next: typeof round = []
216
+ for (const [at, { tree, prefix, recursive }] of asked.entries()) {
217
+ const { entries, truncated } = listed[at]!
218
+ if (truncated && recursive) {
219
+ next.push({ tree, prefix, recursive: false })
220
+ continue
221
+ }
222
+ cut ||= truncated
223
+ blobsOf(entries, prefix, files)
224
+ if (recursive) continue
225
+ for (const item of entries) {
226
+ const path = pathOf(item?.path)
227
+ if (item?.type === 'tree' && path && typeof item.sha === 'string' && SHA.test(item.sha)) next.push({ tree: item.sha, prefix: `${prefix}${path}/`, recursive: true })
228
+ }
229
+ }
230
+ round = next
231
+ }
232
+ return cut
233
+ }
234
+
235
+ // The files commit `commit` holds within the directory `path` ('' the whole
236
+ // repository), as repositoryTree reads them: its whole tree's within it; where
237
+ // that is cut short, some of the directory's perhaps among those left out,
238
+ // the directory's own tree read so (directoryTree), kept by its id, the files
239
+ // nearest it kept. Null for a commit GitHub has none of.
240
+ export async function directoryFiles(fullName: string, token: string | null, commit: string, path: string, kept: TreeStore,
241
+ fetchImpl: typeof fetch = globalThis.fetch): Promise<CommitTree | null> {
242
+ const whole = await repositoryTree(fullName, token, commit, kept, fetchImpl)
243
+ if (!whole || path === '' || !whole.cut) return whole && treeWithin(whole, path)
244
+ const id = await directoryTree(fullName, token, commit, path, fetchImpl)
245
+ const own = id ? await repositoryTree(fullName, token, id, kept, fetchImpl) : null
246
+ return own ? { ...own, files: own.files.map(file => `${path}/${file}`) } : treeWithin(whole, path)
247
+ }
248
+
249
+ // The id of the tree at `path` in commit `commit`, read a level at a time
250
+ // (GET /repos/:owner/:repo/git/trees/:tree, its own entries alone); null
251
+ // where it has none there, or GitHub has no such commit.
252
+ async function directoryTree(fullName: string, token: string | null, commit: string, path: string, fetchImpl: typeof fetch): Promise<string | null> {
253
+ const repo = `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
254
+ let tree = commit
255
+ for (const name of path.split('/')) {
256
+ let data: { tree?: unknown }
257
+ try {
258
+ data = await githubJson(`${repo}/git/trees/${tree}`, token, fetchImpl) as typeof data
259
+ } catch (err) {
260
+ if (err instanceof GithubApiError && (err.message === 'github-not-found' || err.message === 'github-status-422')) return null
261
+ throw err
262
+ }
263
+ if (!Array.isArray(data?.tree)) throw new GithubApiError(502, 'github-malformed')
264
+ const entry = (data.tree as TreeEntry[]).find(item => item?.path === name && item.type === 'tree')
265
+ if (typeof entry?.sha !== 'string' || !SHA.test(entry.sha)) return null
266
+ tree = entry.sha
267
+ }
268
+ return tree
269
+ }
270
+
271
+ // The files of a tree within the directory `path` ('' the whole repository).
272
+ export function treeWithin({ files, sizes, cut }: CommitTree, path: string): CommitTree {
273
+ if (path === '') return { files, sizes, cut }
274
+ const within = [...files.keys()].filter(at => files[at]!.startsWith(`${path}/`))
275
+ return { files: within.map(at => files[at]!), sizes: within.map(at => sizes[at]!), cut }
276
+ }
277
+
278
+ // The commits a request names (`shas`), each once, as many as
279
+ // CHANGES_ASKED; null for none, or for one that isn't a commit id.
280
+ export function changesAsked(shas: string | null): string[] | null {
281
+ if (!shas) return null
282
+ const asked = [...new Set(shas.split(','))]
283
+ return asked.length <= CHANGES_ASKED && asked.every(sha => SHA.test(sha)) ? asked : null
284
+ }