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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) 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 +3 -0
  50. package/common/managed/finding-detail.js +66 -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 +7 -0
  54. package/common/managed/npm-packages.js +51 -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 +359 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +6 -0
  64. package/common/managed/team-app.js +47 -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-2K6UN3PC.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-6LNQTKAV.js +52 -0
  87. package/out/chunk-7BKKYVWR.js +7 -0
  88. package/out/chunk-7C7DWL7I.js +1 -0
  89. package/out/chunk-7JBSSRD7.js +3 -0
  90. package/out/chunk-7VAWINQF.js +6 -0
  91. package/out/chunk-C4Z6ZBXV.js +10 -0
  92. package/out/chunk-FMXWXDR4.js +36 -0
  93. package/out/chunk-GDPCLSOS.js +2 -0
  94. package/out/chunk-GO7FT4OD.js +1 -0
  95. package/out/chunk-HT2SSA3V.js +1 -0
  96. package/out/chunk-IPAH4LKF.js +232 -0
  97. package/out/chunk-IYPY47MC.js +1 -0
  98. package/out/chunk-IZ7POWI3.js +18 -0
  99. package/out/chunk-JCIIBLKR.js +1 -0
  100. package/out/chunk-KOGNQDSJ.js +1 -0
  101. package/out/chunk-NRPC74AM.js +1 -0
  102. package/out/chunk-OOJM4CTU.js +1 -0
  103. package/out/chunk-PBZPYIG7.js +1 -0
  104. package/out/chunk-PNHBZGDD.js +1 -0
  105. package/out/chunk-PWFUOLZK.js +17 -0
  106. package/out/chunk-PXK7EU4V.js +1 -0
  107. package/out/chunk-QB4CBOX2.js +1 -0
  108. package/out/chunk-R3NZFXVR.js +1 -0
  109. package/out/chunk-R5O6FAW4.js +2 -0
  110. package/out/chunk-SXV2T5C4.js +80 -0
  111. package/out/chunk-TQJDYHVE.js +2 -0
  112. package/out/chunk-TUII34K2.js +1 -0
  113. package/out/chunk-VDRXHIHN.js +3 -0
  114. package/out/chunk-VFWMQ3LG.js +2 -0
  115. package/out/chunk-VH4XMZIW.js +2 -0
  116. package/out/chunk-WCPJDAYA.js +10 -0
  117. package/out/chunk-WFYLMF6Z.js +10 -0
  118. package/out/chunk-WRAJVD35.js +1 -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 +364 -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 +23 -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 +76 -0
  237. package/server-managed/comparison.ts +52 -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 +468 -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 +143 -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 +98 -0
  249. package/server-managed/dependency-reviews.ts +364 -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 +376 -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 +136 -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 +5503 -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/listed-activity.ts +39 -0
  273. package/server-managed/locked-packages.ts +152 -0
  274. package/server-managed/lockfile-scope.ts +49 -0
  275. package/server-managed/lru.ts +30 -0
  276. package/server-managed/managed-issues.ts +78 -0
  277. package/server-managed/management-catalog.ts +45 -0
  278. package/server-managed/management.ts +99 -0
  279. package/server-managed/npm-delta-worker.js +23 -0
  280. package/server-managed/npm-delta.ts +194 -0
  281. package/server-managed/npm-insights.ts +449 -0
  282. package/server-managed/npm-kept.ts +225 -0
  283. package/server-managed/npm-loads.ts +264 -0
  284. package/server-managed/npm-manifest.ts +115 -0
  285. package/server-managed/npm-packages.ts +454 -0
  286. package/server-managed/npm-patch.ts +325 -0
  287. package/server-managed/npm-provenance.ts +123 -0
  288. package/server-managed/npm-risk.ts +285 -0
  289. package/server-managed/object-storage-disk.ts +148 -0
  290. package/server-managed/object-storage-vercel.ts +67 -0
  291. package/server-managed/object-storage.ts +75 -0
  292. package/server-managed/package-entry-points.ts +51 -0
  293. package/server-managed/pretty-print.ts +434 -0
  294. package/server-managed/public-workspace.ts +238 -0
  295. package/server-managed/pull-comment-reviews.ts +161 -0
  296. package/server-managed/pull-dependencies.ts +351 -0
  297. package/server-managed/pull-dependency-reviews.ts +334 -0
  298. package/server-managed/pull-reviews.ts +205 -0
  299. package/server-managed/queue.ts +39 -0
  300. package/server-managed/release-commits.ts +84 -0
  301. package/server-managed/rename-diff-worker.js +26 -0
  302. package/server-managed/rename-diff.ts +279 -0
  303. package/server-managed/repo-path.ts +21 -0
  304. package/server-managed/report-migration.ts +35 -0
  305. package/server-managed/report-query.ts +10 -0
  306. package/server-managed/report-response.ts +16 -0
  307. package/server-managed/report-sources.ts +178 -0
  308. package/server-managed/report-store.ts +85 -0
  309. package/server-managed/repository-actions.ts +218 -0
  310. package/server-managed/repository-agents.ts +448 -0
  311. package/server-managed/repository-aliases.ts +91 -0
  312. package/server-managed/repository-browser.ts +294 -0
  313. package/server-managed/repository-changes.ts +284 -0
  314. package/server-managed/repository-code.ts +215 -0
  315. package/server-managed/repository-dependency-reviews.ts +166 -0
  316. package/server-managed/repository-discovery.ts +82 -0
  317. package/server-managed/repository-history.ts +432 -0
  318. package/server-managed/repository-issues.ts +195 -0
  319. package/server-managed/repository-policy.ts +27 -0
  320. package/server-managed/repository-pulls.ts +212 -0
  321. package/server-managed/repository-pulse.ts +460 -0
  322. package/server-managed/repository-risk-assess.ts +105 -0
  323. package/server-managed/repository-risk-audit.ts +79 -0
  324. package/server-managed/repository-risk-read.ts +170 -0
  325. package/server-managed/repository-risk.ts +491 -0
  326. package/server-managed/repository-squashes.ts +168 -0
  327. package/server-managed/request-timing.ts +63 -0
  328. package/server-managed/reviewed-dependencies.ts +149 -0
  329. package/server-managed/revisions.ts +35 -0
  330. package/server-managed/scan-downloads.ts +87 -0
  331. package/server-managed/session.ts +132 -0
  332. package/server-managed/slugs.ts +38 -0
  333. package/server-managed/solidity-entry-points.ts +71 -0
  334. package/server-managed/sql-postgres.ts +34 -0
  335. package/server-managed/sql.ts +88 -0
  336. package/server-managed/static.ts +28 -0
  337. package/server-managed/storage-command.ts +16 -0
  338. package/server-managed/storage-db.ts +183 -0
  339. package/server-managed/storage-encryption.ts +290 -0
  340. package/server-managed/storage-maintenance.ts +259 -0
  341. package/server-managed/storage-payload.ts +121 -0
  342. package/server-managed/storage-stores.ts +91 -0
  343. package/server-managed/storage.ts +82 -0
  344. package/server-managed/team-app.ts +134 -0
  345. package/server-managed/team-catalog.ts +7 -0
  346. package/server-managed/team-feed.ts +180 -0
  347. package/server-managed/team-npm-scopes.ts +106 -0
  348. package/server-managed/team-reports.ts +319 -0
  349. package/server-managed/triage-response.ts +16 -0
  350. package/server-managed/type-only-worker.js +19 -0
  351. package/server-managed/type-only.ts +105 -0
  352. package/server-managed/uploads.ts +106 -0
  353. package/server-managed/upstream-cache.ts +243 -0
  354. package/server-managed/view-as.ts +58 -0
  355. package/server-managed/workspace-shares.ts +169 -0
  356. package/server.ts +70 -0
  357. package/server/index.ts +0 -480
  358. package/server/lifecycle.ts +0 -208
  359. /package/{LICENSE → LICENSE-MIT} +0 -0
  360. /package/{server → server-e2e}/auth.ts +0 -0
  361. /package/{server → server-e2e}/config.example.json +0 -0
  362. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  363. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  364. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  365. /package/{server → server-e2e}/peer.ts +0 -0
  366. /package/{server → server-e2e}/sse-session.ts +0 -0
  367. /package/{server → server-e2e}/util.ts +0 -0
  368. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,376 @@
1
+ // Repository discovery uses the signed-in user token and App installation
2
+ // tokens. Login and repository installation can use one GitHub App: user
3
+ // authorization requests account permissions, installation grants repository
4
+ // permissions. Discovery itself is read-only and skips archived repositories.
5
+ import { Buffer } from 'node:buffer'
6
+ import { createHash, createSign } from 'node:crypto'
7
+ import { createClient } from '@preventive/upstream/github.js'
8
+ import type { ManagedConfig } from './config.ts'
9
+ import { GithubApiError, upstreamGithub } from './github-errors.ts'
10
+ import { USER_AGENT, githubJson, onTokenRejected } from './github-requests.ts'
11
+
12
+ export { GithubApiError } from './github-errors.ts'
13
+ export { githubJson, onTokenRejected } from './github-requests.ts'
14
+
15
+ const GITHUB_API = 'https://api.github.com'
16
+ const PER_PAGE = 100
17
+ const INSTALLATION_TOKEN_TTL_MS = 5 * 60_000
18
+ const INSTALLATION_TOKEN_EXPIRY_MARGIN_MS = 60_000
19
+ const MAX_INSTALLATION_TOKENS = 256
20
+ interface InstallationTokenEntry { token: string | null; expiresAt: number; pending: Promise<string> | null }
21
+ // Credentials only: visibility, user identities, and permissions are never
22
+ // cached here. Bound memory per transport and separate App/key/install scopes.
23
+ const installationTokens = new WeakMap<typeof fetch, Map<string, InstallationTokenEntry>>()
24
+
25
+ // A token GitHub rejected (401): an installation's no longer reused.
26
+ onTokenRejected((token, fetchImpl) => {
27
+ const cache = installationTokens.get(fetchImpl)
28
+ if (!cache) return
29
+ for (const [key, entry] of cache) if (entry.token === token) cache.delete(key)
30
+ })
31
+
32
+ // One listed repository. Carries the context to read its contents later:
33
+ // `installationId` mints an App installation token (Contents: Read) for a repo
34
+ // reached through an installation — null for a public repo listed off the user
35
+ // token, readable without the App — and `defaultBranch` is the ref to read.
36
+ export interface ConnectedRepo {
37
+ id: number
38
+ fullName: string
39
+ private: boolean
40
+ visibility: 'public' | 'private' | 'internal' | null
41
+ htmlUrl: string
42
+ defaultBranch: string
43
+ installationId: number | null
44
+ }
45
+
46
+ // App installation credentials are configured (id + private key present) →
47
+ // private repos can be listed via its installation tokens.
48
+ export function githubAppConfigured(config: ManagedConfig): boolean {
49
+ return config.githubAppId != null && config.githubAppPrivateKey != null
50
+ }
51
+
52
+ // The install URL for the repositories App ("Connect a repository"), or null
53
+ // when no slug is set.
54
+ export function installUrl(config: ManagedConfig): string | null {
55
+ if (config.githubAppSlug == null) return null
56
+ return `https://github.com/apps/${encodeURIComponent(config.githubAppSlug)}/installations/new`
57
+ }
58
+
59
+ // Merge repo lists from every source, deduped by full name and sorted. Later
60
+ // sources win a collision (the handler lists private after public).
61
+ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
62
+ const byName = new Map<string, ConnectedRepo>()
63
+ for (const list of lists) {
64
+ for (const repo of list) byName.set(repo.fullName, repo)
65
+ }
66
+ return [...byName.values()].toSorted((a, b) => a.fullName.localeCompare(b.fullName))
67
+ }
68
+
69
+ // Validate one repo object into a ConnectedRepo (or null to skip), tagging it
70
+ // with the installation it was reached through (null for the user-token path). A
71
+ // missing numeric id or full name → skip (can't key/locate it). Archived repos
72
+ // are skipped — read-only history, not triage targets.
73
+ function parseRepo(raw: unknown, installationId: number | null): ConnectedRepo | null {
74
+ if (raw == null || typeof raw !== 'object') return null
75
+ if ((raw as { archived?: unknown }).archived === true) return null
76
+ const id = (raw as { id?: unknown }).id
77
+ const fullName = (raw as { full_name?: unknown }).full_name
78
+ if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null
79
+ if (typeof fullName !== 'string' || fullName === '') return null
80
+ const htmlUrl = (raw as { html_url?: unknown }).html_url
81
+ const branch = (raw as { default_branch?: unknown }).default_branch
82
+ const visibility = (raw as { visibility?: unknown }).visibility
83
+ return {
84
+ id,
85
+ fullName,
86
+ private: (raw as { private?: unknown }).private === true,
87
+ visibility: visibility === 'public' || visibility === 'private' || visibility === 'internal' ? visibility : null,
88
+ htmlUrl: typeof htmlUrl === 'string' ? htmlUrl : '',
89
+ defaultBranch: typeof branch === 'string' ? branch : '',
90
+ installationId,
91
+ }
92
+ }
93
+
94
+ // Accept an owner/repo or exact GitHub repository URL, never an arbitrary API
95
+ // path. Reject traversal and encoded separators before constructing a fixed URL.
96
+ export function publicRepositoryName(raw: unknown): string | null {
97
+ if (typeof raw !== 'string') return null
98
+ const name = raw.trim().replace(/^https:\/\/github\.com\//iu, '').replace(/\/$/u, '')
99
+ if (!/^[a-z\d](?:[a-z\d-]{0,38})\/[a-z\d_.-]{1,100}$/iu.test(name)) return null
100
+ if (['.', '..'].includes(name.split('/')[1]!)) return null
101
+ return name
102
+ }
103
+
104
+ // Public additions must be readable by the server without the acting user's
105
+ // credentials. Require explicit public visibility and use GitHub's canonical
106
+ // metadata, never client-supplied ids, installation ids, or branches.
107
+ export async function fetchPublicRepository(fullName: string): Promise<ConnectedRepo> {
108
+ if (publicRepositoryName(fullName) !== fullName) throw new GithubApiError(400, 'bad-repository')
109
+ const repo = parseRepo(await upstreamGithub(() => createClient({ token: null, userAgent: USER_AGENT }).getRepo({ repo: fullName })), null)
110
+ if (repo == null || repo.private || repo.visibility !== 'public') throw new GithubApiError(409, 'repo-not-public')
111
+ if (publicRepositoryName(repo.fullName) !== repo.fullName || repo.fullName.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(502, 'github-malformed')
112
+ return { ...repo, htmlUrl: `https://github.com/${repo.fullName}` }
113
+ }
114
+
115
+ // ── PUBLIC: the user's own repos via their login token ──
116
+
117
+ // List the authenticated user's repositories (GET /user/repos), paginated until
118
+ // a short page, deduped + sorted. With an identity-only token this returns the
119
+ // user's PUBLIC repos. READ-ONLY.
120
+ export async function listUserRepos(accessToken: string): Promise<ConnectedRepo[]> {
121
+ const byName = new Map<string, ConnectedRepo>()
122
+ const body = await upstreamGithub(() => createClient({ token: accessToken, userAgent: USER_AGENT }).listUserRepos())
123
+ for (const r of body) {
124
+ const repo = parseRepo(r, null)
125
+ if (repo != null) byName.set(repo.fullName, repo)
126
+ }
127
+ return [...byName.values()].toSorted((a, b) => a.fullName.localeCompare(b.fullName))
128
+ }
129
+
130
+ // ── PRIVATE: App installations via installation tokens ──
131
+
132
+ function base64url(input: Buffer | string): string {
133
+ return Buffer.from(input).toString('base64url')
134
+ }
135
+
136
+ // Mint a short-lived App JWT (RS256) for the repositories App. iat backdated 60s
137
+ // for clock skew; exp 8 min out (within GitHub's 10-min cap); iss = the App id.
138
+ export function appJwt(appId: string, privateKeyPem: string, now: number = Date.now()): string {
139
+ const seconds = Math.floor(now / 1000)
140
+ const iat = seconds - 60
141
+ const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
142
+ const payload = base64url(JSON.stringify({ iat, exp: seconds + 480, iss: appId }))
143
+ const signingInput = `${header}.${payload}`
144
+ return `${signingInput}.${base64url(createSign('RSA-SHA256').update(signingInput).sign(privateKeyPem))}`
145
+ }
146
+
147
+ // The repositories App's installation ids (one per org/user that installed it).
148
+ async function listInstallationIds(jwt: string, fetchImpl: typeof fetch): Promise<number[]> {
149
+ const ids: number[] = []
150
+ for (let page = 1; ; page++) {
151
+ const body = await githubJson(`${GITHUB_API}/app/installations?per_page=${PER_PAGE}&page=${page}`, jwt, fetchImpl)
152
+ if (!Array.isArray(body)) throw new GithubApiError(502, 'github-malformed')
153
+ for (const inst of body) {
154
+ const id = (inst as { id?: unknown }).id
155
+ if (typeof id === 'number' && Number.isSafeInteger(id) && id > 0) ids.push(id)
156
+ }
157
+ if (body.length < PER_PAGE) break
158
+ }
159
+ return ids
160
+ }
161
+
162
+ // Reuse installation credentials only in this process. Refresh well before
163
+ // expiry and coalesce concurrent mints; failed or undated tokens are not cached.
164
+ async function installationAccessToken(appId: string, privateKey: string, installId: number, fetchImpl: typeof fetch): Promise<string> {
165
+ let cache = installationTokens.get(fetchImpl)
166
+ if (!cache) { cache = new Map(); installationTokens.set(fetchImpl, cache) }
167
+ const now = Date.now()
168
+ for (const [key, entry] of cache) if (!entry.pending && entry.expiresAt <= now) cache.delete(key)
169
+ const key = JSON.stringify([appId, createHash('sha256').update(privateKey).digest('hex'), installId])
170
+ const cached = cache.get(key)
171
+ if (cached) {
172
+ cache.delete(key)
173
+ cache.set(key, cached)
174
+ if (cached.pending) return cached.pending
175
+ return cached.token!
176
+ }
177
+ const entry: InstallationTokenEntry = { token: null, expiresAt: 0, pending: null }
178
+ const pending = Promise.resolve().then(async () => {
179
+ try {
180
+ const jwt = appJwt(appId, privateKey)
181
+ const body = await githubJson(`${GITHUB_API}/app/installations/${installId}/access_tokens`, jwt, fetchImpl, 'POST') as { token?: unknown; expires_at?: unknown }
182
+ if (typeof body?.token !== 'string' || body.token === '') throw new GithubApiError(502, 'github-install-token-denied')
183
+ entry.token = body.token
184
+ const expiresAt = typeof body.expires_at === 'string' ? Date.parse(body.expires_at) : NaN
185
+ entry.expiresAt = Number.isFinite(expiresAt) ? Math.min(now + INSTALLATION_TOKEN_TTL_MS, expiresAt - INSTALLATION_TOKEN_EXPIRY_MARGIN_MS) : 0
186
+ if (entry.expiresAt <= Date.now() && cache.get(key) === entry) cache.delete(key)
187
+ return body.token
188
+ } catch (err) {
189
+ if (cache.get(key) === entry) cache.delete(key)
190
+ throw err
191
+ } finally { entry.pending = null }
192
+ })
193
+ entry.pending = pending
194
+ cache.set(key, entry)
195
+ if (cache.size > MAX_INSTALLATION_TOKENS) cache.delete(cache.keys().next().value!)
196
+ return await pending
197
+ }
198
+
199
+ // Resolve this exact repository with the App's identity, then confirm the
200
+ // installation token reaches the same numeric repository. A missing grant is
201
+ // actionable through installation; transport/auth/rate errors must stay errors.
202
+ export async function repositoryInstallation(config: ManagedConfig, repoId: number, fullName: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<number | null> {
203
+ const { githubAppId, githubAppPrivateKey } = config
204
+ if (githubAppId == null || githubAppPrivateKey == null) throw new GithubApiError(503, 'github-app-not-configured')
205
+ if (publicRepositoryName(fullName) !== fullName) throw new GithubApiError(409, 'repo-identity-changed')
206
+ const path = fullName.split('/').map(encodeURIComponent).join('/')
207
+ const jwt = appJwt(githubAppId, githubAppPrivateKey)
208
+ let raw
209
+ try { raw = await githubJson(`${GITHUB_API}/repos/${path}/installation`, jwt, fetchImpl) } catch (err) {
210
+ if (err instanceof GithubApiError && err.status === 404) return null
211
+ throw err
212
+ }
213
+ const installation = raw as { id?: unknown; suspended_at?: unknown; permissions?: { contents?: unknown } } | null
214
+ const id = installation?.id
215
+ if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) throw new GithubApiError(502, 'github-malformed')
216
+ if (installation?.suspended_at != null || !['read', 'write'].includes(String(installation?.permissions?.contents))) return null
217
+ const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
218
+ const repo = parseRepo(await githubJson(`${GITHUB_API}/repos/${path}`, token, fetchImpl), id)
219
+ if (repo == null || repo.id !== repoId || repo.fullName.toLowerCase() !== fullName.toLowerCase()) throw new GithubApiError(409, 'repo-identity-changed')
220
+ return id
221
+ }
222
+
223
+ // The `repositories` array of one /installation/repositories page → ConnectedRepo[],
224
+ // each tagged with the installation id that can read it.
225
+ function repoPage(body: unknown, installationId: number): ConnectedRepo[] {
226
+ const repositories = (body as { repositories?: unknown }).repositories
227
+ if (!Array.isArray(repositories)) return []
228
+ const out: ConnectedRepo[] = []
229
+ for (const r of repositories) {
230
+ const repo = parseRepo(r, installationId)
231
+ if (repo != null) out.push(repo)
232
+ }
233
+ return out
234
+ }
235
+
236
+ // Every repository one installation can read, paginated by the page count from
237
+ // `total_count` (avoids Link-header parsing).
238
+ async function listInstallationRepos(token: string, installationId: number, fetchImpl: typeof fetch): Promise<ConnectedRepo[]> {
239
+ const pageUrl = (page: number): string => `${GITHUB_API}/installation/repositories?per_page=${PER_PAGE}&page=${page}`
240
+ const first = await githubJson(pageUrl(1), token, fetchImpl) as { total_count?: unknown }
241
+ const repos = repoPage(first, installationId)
242
+ const total = typeof first.total_count === 'number' ? first.total_count : repos.length
243
+ const pages = Math.ceil(total / PER_PAGE)
244
+ for (let page = 2; page <= pages; page++) {
245
+ repos.push(...repoPage(await githubJson(pageUrl(page), token, fetchImpl), installationId))
246
+ }
247
+ return repos
248
+ }
249
+
250
+ // List every repo the repositories App is installed on, across installations
251
+ // (public + PRIVATE within those installs), deduped + sorted. Empty when the App
252
+ // isn't configured. READ-ONLY.
253
+ export async function listInstalledRepos(config: ManagedConfig, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
254
+ const { githubAppId, githubAppPrivateKey } = config
255
+ if (githubAppId == null || githubAppPrivateKey == null) return []
256
+ const jwt = appJwt(githubAppId, githubAppPrivateKey)
257
+ const lists = await mapGithubRequests(await listInstallationIds(jwt, fetchImpl), async (id) => {
258
+ const token = await installationAccessToken(githubAppId, githubAppPrivateKey, id, fetchImpl)
259
+ return listInstallationRepos(token, id, fetchImpl)
260
+ })
261
+ return mergeRepos(...lists)
262
+ }
263
+
264
+ // Bound fan-out while keeping independent installations/permission checks off
265
+ // one long serial critical path. Preserve input order for deterministic merges.
266
+ async function mapGithubRequests<T, U>(items: T[], work: (item: T) => Promise<U>): Promise<U[]> {
267
+ const results: U[] = []
268
+ let next = 0
269
+ await Promise.all(Array.from({ length: Math.min(4, items.length) }, async () => {
270
+ while (next < items.length) {
271
+ const index = next++
272
+ results[index] = await work(items[index]!)
273
+ }
274
+ }))
275
+ return results
276
+ }
277
+
278
+ // A GitHub account, as GitHub's /user tells it.
279
+ export interface GithubIdentity { id: number; login: string }
280
+
281
+ // The login App and repository App are separate: /user/repos with the login
282
+ // token is not evidence of private-repo access. Ask the repository App for the
283
+ // user's effective GitHub permission (including teams/org/enterprise grants).
284
+ // This endpoint accepts installation tokens with Metadata: read; it does NOT
285
+ // require Administration permission (unlike collaborator mutation endpoints).
286
+ // https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user
287
+ // Resolve the current login from /user so renamed handles cannot check someone
288
+ // else's access. All repo paths come from GitHub's installation catalogue.
289
+ export async function githubUserIdentity(userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<GithubIdentity> {
290
+ const identity = await githubJson(`${GITHUB_API}/user`, userToken, fetchImpl) as { id?: unknown; login?: unknown }
291
+ if (typeof identity?.login !== 'string' || !identity.login || typeof identity.id !== 'number' || !Number.isSafeInteger(identity.id) || identity.id <= 0) throw new GithubApiError(502, 'github-malformed')
292
+ return { id: identity.id, login: identity.login }
293
+ }
294
+
295
+ // The permission's level (`admin`, `write`, `read`), as GitHub tells it of
296
+ // `identity` alone; `none` where it tells of none, or of another account.
297
+ export async function githubRepoPermission(fullName: string, token: string, identity: GithubIdentity, fetchImpl: typeof fetch = globalThis.fetch): Promise<string> {
298
+ const path = fullName.split('/').map(encodeURIComponent).join('/')
299
+ try {
300
+ const body = await githubJson(`${GITHUB_API}/repos/${path}/collaborators/${encodeURIComponent(identity.login)}/permission`, token, fetchImpl) as { permission?: unknown; user?: { id?: unknown } }
301
+ return body?.user?.id === identity.id && typeof body.permission === 'string' ? body.permission : 'none'
302
+ } catch (err) {
303
+ if (err instanceof GithubApiError && err.status === 404) return 'none'
304
+ throw err // Never broaden access when GitHub cannot verify permission.
305
+ }
306
+ }
307
+
308
+ // The levels that read a repository.
309
+ export const READ_PERMISSIONS: ReadonlySet<string> = new Set(['read', 'write', 'admin'])
310
+
311
+ export async function githubRepoReadPermission(fullName: string, token: string, identity: GithubIdentity, fetchImpl: typeof fetch = globalThis.fetch): Promise<boolean> {
312
+ return READ_PERMISSIONS.has(await githubRepoPermission(fullName, token, identity, fetchImpl))
313
+ }
314
+
315
+ export async function filterInstalledRepos(config: ManagedConfig, repositories: ConnectedRepo[], userToken: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<ConnectedRepo[]> {
316
+ const identity = await githubUserIdentity(userToken, fetchImpl)
317
+ const tokens = new Map<number, Promise<string | null>>()
318
+ const allowed = await mapGithubRequests(repositories, async (repo) => {
319
+ // Internal repos may have private=false; missing visibility is not proof
320
+ // of public access either. Only explicitly public repositories skip checks.
321
+ if (repo.visibility === 'public') return true
322
+ if (repo.installationId == null) return false
323
+ let token = tokens.get(repo.installationId)
324
+ if (!token) {
325
+ token = repoAccessToken(config, repo.installationId, fetchImpl)
326
+ tokens.set(repo.installationId, token)
327
+ }
328
+ const accessToken = await token
329
+ if (!accessToken) return false
330
+ return githubRepoReadPermission(repo.fullName, accessToken, identity, fetchImpl)
331
+ })
332
+ return repositories.filter((_, index) => allowed[index])
333
+ }
334
+
335
+ // Get a token to READ a selected repo's contents: an installation token (the
336
+ // App's Contents: Read) when the repo was reached through an installation, or
337
+ // null for a public repo readable unauthenticated. This is the whole point of
338
+ // persisting `installationId` — the stored context is enough to read.
339
+ export async function repoAccessToken(config: ManagedConfig, installationId: number | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<string | null> {
340
+ const { githubAppId, githubAppPrivateKey } = config
341
+ if (installationId == null || githubAppId == null || githubAppPrivateKey == null) return null
342
+ return await installationAccessToken(githubAppId, githubAppPrivateKey, installationId, fetchImpl)
343
+ }
344
+
345
+ // ── merged listing ──
346
+
347
+ export interface RepoListing {
348
+ repositories: ConnectedRepo[]
349
+ tokenMissing: boolean
350
+ }
351
+
352
+ // Every repo a user can reach, merged (deduped + sorted): PUBLIC via their login
353
+ // token + PRIVATE via the App's installations. Each source is best-effort so one
354
+ // failing never blanks the other; `tokenMissing` flags a missing/stale user
355
+ // token (→ the page prompts re-login). A repo seen on both sides keeps the
356
+ // installation tag (private listed last → wins the merge).
357
+ export async function collectRepos(config: ManagedConfig, userToken: string | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<RepoListing> {
358
+ let tokenMissing = userToken == null
359
+ const lists: ConnectedRepo[][] = []
360
+ if (userToken != null) {
361
+ try {
362
+ lists.push(await listUserRepos(userToken))
363
+ } catch (err) {
364
+ if (err instanceof GithubApiError && err.status === 401) tokenMissing = true
365
+ else console.warn('managed: public repo list failed:', err)
366
+ }
367
+ }
368
+ if (githubAppConfigured(config)) {
369
+ try {
370
+ lists.push(await listInstalledRepos(config, fetchImpl))
371
+ } catch (err) {
372
+ console.warn('managed: private repo list failed:', err)
373
+ }
374
+ }
375
+ return { repositories: mergeRepos(...lists), tokenMissing }
376
+ }
@@ -0,0 +1,70 @@
1
+ import { HttpError } from '@preventive/upstream/github.js'
2
+
3
+ // A failure carrying the HTTP status the router should surface. 401 passes
4
+ // through so the user-token path can map it to "log in again".
5
+ export class GithubApiError extends Error {
6
+ status: number
7
+ retryAfter: number | null
8
+ // What failed, where GitHub wasn't reached (githubUnreachable).
9
+ detail: string | null
10
+ constructor(status: number, message: string, retryAfter: number | null = null, detail: string | null = null) {
11
+ super(message)
12
+ this.name = 'GithubApiError'
13
+ this.status = status
14
+ this.retryAfter = retryAfter
15
+ this.detail = detail
16
+ }
17
+ }
18
+
19
+ // GitHub not reached, or its answer not read whole, `started` ms since the
20
+ // epoch: as `github-unreachable`, with what failed as the error and its causes
21
+ // tell it (a timeout, a connection closed, an answer too large) and after how
22
+ // long, for an admin's page to show (repository-risk.ts). Only the errors'
23
+ // names, codes and messages, never GitHub's text nor a request's headers.
24
+ export function githubUnreachable(error: unknown, started: number): GithubApiError {
25
+ const told: string[] = []
26
+ for (let at = error, depth = 0; at instanceof Error && depth < 4; at = at.cause, depth++) {
27
+ const code = 'code' in at && typeof at.code === 'string' && at.code !== at.name ? ` ${at.code}` : ''
28
+ told.push(`${at.name}${code}${at.message ? `: ${at.message.slice(0, 200)}` : ''}`)
29
+ }
30
+ const failed = told.length > 0 ? told.join('; ') : String(error).slice(0, 200)
31
+ return new GithubApiError(502, 'github-unreachable', null, `${failed}, after ${((Date.now() - started) / 1000).toFixed(1)}s`)
32
+ }
33
+
34
+ export async function throwGithubResponseError(res: Response): Promise<never> {
35
+ if (res.status === 403 || res.status === 429) {
36
+ const body = await res.json().catch(() => null) as { message?: unknown } | null
37
+ if (res.status === 429 || res.headers.get('x-ratelimit-remaining') === '0' || res.headers.has('retry-after')
38
+ || (typeof body?.message === 'string' && /rate limit|abuse detection/iu.test(body.message))) {
39
+ const retry = Number(res.headers.get('retry-after'))
40
+ const reset = Number(res.headers.get('x-ratelimit-reset')) * 1000
41
+ const wait = Math.max(Number.isFinite(retry) ? retry : 0, Number.isFinite(reset) ? (reset - Date.now()) / 1000 : 0)
42
+ throw new GithubApiError(429, 'github-rate-limited', Math.max(60, Math.ceil(wait)))
43
+ }
44
+ }
45
+ if (res.status === 401) throw new GithubApiError(401, 'github-unauthorized')
46
+ if (res.status === 404) throw new GithubApiError(404, 'github-not-found')
47
+ throw new GithubApiError(502, `github-status-${res.status}`)
48
+ }
49
+
50
+ export async function upstreamGithub<T>(request: () => Promise<T>): Promise<T> {
51
+ const started = Date.now()
52
+ try { return await request() } catch (error) {
53
+ // Already told as the router surfaces it.
54
+ if (error instanceof GithubApiError) throw error
55
+ if (error instanceof HttpError) {
56
+ // The upstream error includes GitHub's response text. Retain managed
57
+ // status handling, including rate limits, without exposing that text.
58
+ if (error.status === 401) throw new GithubApiError(401, 'github-unauthorized')
59
+ if (error.status === 404) throw new GithubApiError(404, 'github-not-found')
60
+ if (error.status === 429 || (error.status === 403 && /rate limit|abuse detection/iu.test(error.message))) {
61
+ throw new GithubApiError(429, 'github-rate-limited', 60)
62
+ }
63
+ throw new GithubApiError(502, `github-status-${error.status}`)
64
+ }
65
+ if (error instanceof Error && (error.name === 'AssertionError' || error.cause instanceof SyntaxError)) {
66
+ throw new GithubApiError(502, 'github-malformed')
67
+ }
68
+ throw githubUnreachable(error, started)
69
+ }
70
+ }
@@ -0,0 +1,39 @@
1
+ // GitHub's GraphQL API, a query at a time, as the repository readers ask it
2
+ // (repository-history.ts, repository-agents.ts, repository-pulse.ts and the rest).
3
+ import { GithubApiError, githubJson } from './github-app.ts'
4
+
5
+ export type GraphqlError = { type?: unknown; path?: unknown; message?: unknown }
6
+
7
+ const NAMED_ERRORS = new Set(['RATE_LIMITED', 'NOT_FOUND', 'FORBIDDEN'])
8
+ // GitHub's own words for a query it stopped past its time (ten seconds):
9
+ // "Something went wrong while executing your query. This may be the result
10
+ // of a timeout, …", with no type.
11
+ const TIMED_OUT = /\btimeout\b/iu
12
+
13
+ // One GraphQL query with `token`, answered with its `data`: one GitHub answers
14
+ // with errors, but for those the caller reads past (`tolerated`, the fields
15
+ // they name then null), is rate limited, names what isn't there, is refused,
16
+ // as one a GitHub App lacks the permission for is, or a token the scope for
17
+ // (`INSUFFICIENT_SCOPES`, a sign-in's having none), took GitHub too long, or
18
+ // else is malformed, as one GitHub rejects unread is (a field or type its API
19
+ // doesn't know). GitHub's words for errors of none of those first three types
20
+ // are logged, those read past too.
21
+ export async function githubGraphql(query: string, variables: Record<string, unknown>, token: string, fetchImpl: typeof fetch,
22
+ tolerated: (error: GraphqlError) => boolean = () => false) {
23
+ const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }, true) as { data?: unknown; errors?: GraphqlError[] } | null
24
+ const all = Array.isArray(body?.errors) ? body.errors : []
25
+ const unnamed = all.filter(error => !NAMED_ERRORS.has(String(error?.type)))
26
+ if (unnamed.length > 0) {
27
+ console.warn('managed: GitHub answered a query with errors:', JSON.stringify(unnamed.slice(0, 3)
28
+ .map(error => ({ type: error?.type, message: String(error?.message).slice(0, 300), path: error?.path }))))
29
+ }
30
+ const errors = all.filter(error => !tolerated(error))
31
+ if (errors.length > 0) {
32
+ if (errors.some(error => error?.type === 'RATE_LIMITED')) throw new GithubApiError(429, 'github-rate-limited', 60)
33
+ if (errors.some(error => error?.type === 'NOT_FOUND')) throw new GithubApiError(404, 'github-not-found')
34
+ if (errors.some(error => error?.type === 'FORBIDDEN' || error?.type === 'INSUFFICIENT_SCOPES')) throw new GithubApiError(502, 'github-status-403')
35
+ if (errors.some(error => typeof error?.message === 'string' && TIMED_OUT.test(error.message))) throw new GithubApiError(502, 'github-timeout')
36
+ throw new GithubApiError(502, 'github-malformed')
37
+ }
38
+ return body?.data
39
+ }
@@ -0,0 +1,140 @@
1
+ import { type GithubFixResult, githubIssueClosedReason, isGithubRepoName, parseGithubFixUrl } from '../common/github-pr.ts'
2
+ import type { ManagedConfig } from './config.ts'
3
+ import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
4
+ import type { ManagedIssue } from './managed-issues.ts'
5
+ import { ensureUserAccessToken } from './github-oauth.ts'
6
+ import { lookupFixes } from './github-pulls.ts'
7
+
8
+ export type IssueFixUpdate = { findingId: string; issueUrl: string; previous: string | null; next: string | null; checkedAt: number }
9
+ type Pull = { number: number; title: string; body: string | null; state: string; isDraft: boolean; createdAt: string;
10
+ repository: { databaseId: number; nameWithOwner: string } }
11
+ type Connection = { nodes: Pull[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } }
12
+ type Issue = { number: number; title: string; body: string | null; state: string; stateReason: string | null; closedByPullRequestsReferences?: Connection }
13
+ type Repository = TeamReportAccessSnapshot['repositories'][number]
14
+ type Job = { repo: Repository; kind: 'pull' | 'issue'; number: number; aliases: string[];
15
+ linked: boolean; cursor: string | null; pulls: Pull[]; complete: boolean }
16
+ type Metadata = Exclude<GithubFixResult, { error: string }>
17
+ const pullFields = 'number title body state isDraft createdAt repository { databaseId nameWithOwner }'
18
+
19
+ function metadataQuery(jobs: Job[]) {
20
+ return `{ ${jobs.map((job, index) => {
21
+ const [owner, name] = job.repo.github.split('/')
22
+ const fields = job.kind === 'pull' ? pullFields : `number title body state stateReason ${job.linked
23
+ ? `closedByPullRequestsReferences(first: 100, includeClosedPrs: true, after: ${JSON.stringify(job.cursor)}) { nodes { ${pullFields} } pageInfo { hasNextPage endCursor } }` : ''}`
24
+ return `r${index}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { databaseId nameWithOwner item: ${job.kind === 'pull' ? 'pullRequest' : 'issue'}(number: ${job.number}) { ${fields} } }`
25
+ }).join('\n')} }`
26
+ }
27
+
28
+ function itemMetadata(item: Issue | Pull, kind: Job['kind']): Omit<Metadata, 'url'> | null {
29
+ if (!item || typeof item.title !== 'string' || !item.title.trim() || item.title.length > 1024
30
+ || item.body != null && typeof item.body !== 'string' || !['OPEN', 'CLOSED', ...(kind === 'pull' ? ['MERGED'] : [])].includes(item.state)) return null
31
+ const status = item.state === 'MERGED' ? 'merged' : item.state === 'CLOSED' ? 'closed' : 'isDraft' in item && item.isDraft === true ? 'draft' : 'open'
32
+ const stateReason = kind === 'issue' && status === 'closed' ? githubIssueClosedReason(String((item as Issue).stateReason).toLowerCase()) : null
33
+ return { title: item.title, description: item.body ?? null, status, stateReason }
34
+ }
35
+
36
+ function readPage(job: Job, item: Issue | Pull, metadata: Map<string, Metadata>): boolean {
37
+ const details = itemMetadata(item, job.kind)
38
+ if (!details || item.number !== job.number) return false
39
+ for (const url of job.aliases) metadata.set(url, { url, ...details })
40
+ if (!job.linked) return false
41
+ const connection = (item as Issue).closedByPullRequestsReferences
42
+ if (!connection || !Array.isArray(connection.nodes) || typeof connection.pageInfo?.hasNextPage !== 'boolean'
43
+ || connection.nodes.some(pr => !pr || !Number.isSafeInteger(pr.number) || pr.number <= 0 || typeof pr.createdAt !== 'string'
44
+ || !Number.isFinite(Date.parse(pr.createdAt)) || !itemMetadata(pr, 'pull') || typeof pr.isDraft !== 'boolean'
45
+ || !Number.isSafeInteger(pr.repository?.databaseId) || typeof pr.repository?.nameWithOwner !== 'string' || !isGithubRepoName(pr.repository.nameWithOwner))) return false
46
+ job.pulls.push(...connection.nodes)
47
+ if (!connection.pageInfo.hasNextPage) { job.complete = true; return false }
48
+ if (typeof connection.pageInfo.endCursor !== 'string' || !connection.pageInfo.endCursor || connection.pageInfo.endCursor === job.cursor) return false
49
+ job.cursor = connection.pageInfo.endCursor
50
+ return true
51
+ }
52
+
53
+ function issueUpdates(jobs: Job[], issues: ManagedIssue[], allowed: Map<string, Repository>, checkedAt: number, metadata: Map<string, Metadata>) {
54
+ const updates: IssueFixUpdate[] = []
55
+ // The issues each job's issue is, by repository and number, read once.
56
+ const byIssue = new Map<string, ManagedIssue[]>()
57
+ for (const issue of issues) {
58
+ const ref = parseGithubFixUrl(issue.issueUrl)
59
+ if (ref?.kind !== 'issue') continue
60
+ const key = `${issue.repoId}:${ref.number}`, same = byIssue.get(key)
61
+ if (same) same.push(issue)
62
+ else byIssue.set(key, [issue])
63
+ }
64
+ for (const job of jobs) {
65
+ if (!job.linked || !job.complete) continue
66
+ // "Last" means the newest PR by creation time, never its latest comment.
67
+ const latest = job.pulls.filter(pr => pr.state !== 'CLOSED').toSorted((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || b.number - a.number)[0]
68
+ const repo = latest && allowed.get(latest.repository.nameWithOwner.toLowerCase())
69
+ // The viewer's token can have broader access than this workspace. Never
70
+ // persist or disclose a PR from outside the workspace repository grants.
71
+ if (latest && (!repo || repo.repoId !== latest.repository.databaseId)) continue
72
+ const next = latest ? `https://github.com/${repo!.github}/pull/${latest.number}` : null
73
+ if (latest && next) metadata.set(next, { url: next, ...itemMetadata(latest, 'pull')! })
74
+ // Only a changed derived Fix is written: an unchanged one's check time
75
+ // alone would bump the annotations revision that every open team feed
76
+ // polls, for nothing they show. Changes still stamp it, so an older lookup
77
+ // never replaces a newer change (managed-issues.ts). An older lookup that
78
+ // differs can now land after a newer unchanged one, as it could already
79
+ // land before it (whose compare-and-set then fails); either way the next
80
+ // lookup finds the stored Fix differs, and writes the current one.
81
+ for (const issue of byIssue.get(`${job.repo.repoId}:${job.number}`) ?? []) {
82
+ if (issue.autoFixUrl !== next) updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
83
+ }
84
+ }
85
+ return updates
86
+ }
87
+
88
+ // Fetch issue details, linked PR states and manual Fix metadata in one batch.
89
+ // Pagination and workspaces with over 200 distinct links need further batches.
90
+ // Live GraphQL results prove this viewer's access; never publish them into the
91
+ // REST shared cache under an older public-visibility grant.
92
+ export async function lookupIssueLinks(config: ManagedConfig, db: ManagedDb, snapshot: TeamReportAccessSnapshot,
93
+ urls: string[], issues: ManagedIssue[], fetchImpl: typeof fetch = fetch): Promise<{ fixes: GithubFixResult[]; updates: IssueFixUpdate[] }> {
94
+ const checkedAt = Date.now(), signal = AbortSignal.timeout(10_000)
95
+ const allowed = new Map(snapshot.repositories.filter(repo => isGithubRepoName(repo.github)).map(repo => [repo.github.toLowerCase(), repo]))
96
+ const jobs = new Map<string, Job>()
97
+ const linked = new Set(issues.filter(issue => issue.issueUrl).map(issue => {
98
+ const ref = parseGithubFixUrl(issue.issueUrl)
99
+ return ref?.kind === 'issue' ? `${issue.repoId}:issue:${ref.number}` : ''
100
+ }))
101
+ for (const url of urls) {
102
+ const ref = parseGithubFixUrl(url), repo = ref && allowed.get(ref.repo.toLowerCase())
103
+ if (!ref || !repo) continue
104
+ const key = `${repo.repoId}:${ref.kind}:${ref.number}`, previous = jobs.get(key)
105
+ if (previous) { previous.aliases.push(url); continue }
106
+ jobs.set(key, { repo, kind: ref.kind, number: ref.number, aliases: [url], linked: linked.has(key), cursor: null, pulls: [], complete: false })
107
+ }
108
+ if (jobs.size === 0) return { fixes: [], updates: [] }
109
+ const withinDeadline: typeof fetch = (input, init) => { signal.throwIfAborted(); return fetchImpl(input, { ...init, signal }) }
110
+ const token = await ensureUserAccessToken(config, db, snapshot.user.id, checkedAt, withinDeadline)
111
+ if (!token) return { fixes: await lookupFixes(config, db, snapshot, urls, withinDeadline), updates: [] }
112
+ const metadata = new Map<string, Metadata>(), selected = [...jobs.values()]
113
+ let pending = selected
114
+ for (let page = 0; pending.length > 0 && page < 100 && !signal.aborted; page++) {
115
+ const batch = pending.slice(0, 200), query = metadataQuery(batch)
116
+ let data: Record<string, { databaseId: number; nameWithOwner: string; item: Issue | Pull } | null>
117
+ try {
118
+ const response = await withinDeadline('https://api.github.com/graphql', {
119
+ method: 'POST', redirect: 'error',
120
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'user-agent': 'deepview-triage' }, body: JSON.stringify({ query }),
121
+ })
122
+ if (!response.ok) break
123
+ const body = await response.json() as { data?: typeof data; errors?: { path?: unknown[] }[] }
124
+ // Errors attached to an alias invalidate that whole job, including its
125
+ // connection. Other aliases can succeed even if one issue was deleted.
126
+ if (!body?.data || body.errors != null && (!Array.isArray(body.errors)
127
+ || body.errors.some(error => typeof error?.path?.[0] !== 'string' || !/^r\d+$/u.test(error.path[0])))) break
128
+ data = body.data
129
+ for (const error of body.errors ?? []) data[error.path![0] as string] = null
130
+ } catch { break }
131
+ pending = pending.slice(batch.length)
132
+ for (const [index, job] of batch.entries()) {
133
+ const repository = data[`r${index}`]
134
+ if (repository?.databaseId !== job.repo.repoId || typeof repository.nameWithOwner !== 'string' || repository.nameWithOwner.toLowerCase() !== job.repo.github.toLowerCase()) continue
135
+ if (readPage(job, repository.item, metadata)) pending.push(job)
136
+ }
137
+ }
138
+ const updates = issueUpdates(selected, issues, allowed, checkedAt, metadata)
139
+ return { fixes: [...metadata.values()], updates }
140
+ }