@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,432 @@
1
+ // The commit history the code viewer's History tab draws
2
+ // (GET /api/admin/repositories/history), as `git log --oneline --decorate
3
+ // --graph` shows it: each commit's id, parents, subject, author and time, and
4
+ // the branches and tags pointing at commits. From a revision, a page at a
5
+ // time, of the whole repository or of the commits touching a directory, as
6
+ // GitHub's REST API lists them, for any reader; or, as `--all`, across the
7
+ // repository's most recently committed to branches at once, then on from where
8
+ // those stop, in GraphQL queries, which need a token. A whole repository's is
9
+ // read a page from a commit at a time, each page kept by the commit it starts
10
+ // at (historyFrom): a commit's history never changes, and where a commit read
11
+ // starts a page kept, that page glues on, and those after it.
12
+ import { type AgentSignals, agentSignals } from '../common/managed/agent-tasks.ts'
13
+ import { githubAgent } from '../common/managed/github-agents.ts'
14
+ import { AGENT_MARKS_READ, APP_AGENTS } from './app-agents.ts'
15
+ import { type CommitFacts, MAX_COAUTHORS, type Maker, coauthorFacts, coauthorTrailers } from './commit-coauthors.ts'
16
+ import { GithubApiError, githubJson } from './github-app.ts'
17
+ import { type GraphActor, accountOf, commitAccount, linkNoreplyUsers } from './github-accounts.ts'
18
+ import { githubGraphql } from './github-graphql.ts'
19
+
20
+ export { ACTOR, type GraphAccount, type GraphActor, type NamedAccount, accountOf, actorOf, commitAccount, linkNoreplyUsers } from './github-accounts.ts'
21
+ export { type GraphqlError, githubGraphql } from './github-graphql.ts'
22
+
23
+ export const HISTORY_PAGE = 100
24
+ // The deepest page read, as far as Older commits goes.
25
+ export const MAX_HISTORY_PAGE = 100
26
+ // `--all`: the branches most recently committed to, of as many as fifty
27
+ // pages of them list, the latest commits of each (as many as a GraphQL connection
28
+ // gives), and the latest tags.
29
+ export const ALL_BRANCHES = 25
30
+ const MAX_HEAD_PAGES = 50
31
+ const ALL_DEPTH = 100
32
+ const ALL_TAGS = 100
33
+ const REF_TIPS = 100
34
+ const MAX_PARENTS = 8
35
+ const MAX_SUBJECT = 1000
36
+ const MAX_NAME = 256
37
+ const SHA = /^[a-f\d]{40}$/u
38
+
39
+ // A commit, its author by name, address (`authorEmail`) and their account's
40
+ // login and numeric id (`authorId`), and who it names beside them
41
+ // (commit-coauthors.ts coauthorFacts), each absent from pages kept before it
42
+ // was read, with the lines it adds and removes and when it was authored in
43
+ // its author's own time zone (`authorZone`, minutes east of UTC), where
44
+ // GitHub's GraphQL API tells them (null where its REST API lists it); and the
45
+ // marks an agent left in its message, where any (messageMarks), by the rules
46
+ // they were read by (`marked`, AGENT_MARKS_READ; absent from pages kept
47
+ // before). What of them is an agent's is told as they are answered
48
+ // (coauthorsOf, historyMarks), never kept.
49
+ export interface HistoryCommit extends CommitFacts { sha: string; parents: string[]; subject: string; committedAt: number | null; additions?: number | null
50
+ deletions?: number | null; authoredAt?: number | null; authorZone?: number | null; marks?: AgentSignals; marked?: number }
51
+ export interface HistoryRef { name: string; sha: string }
52
+
53
+ const textOf = (value: unknown, max: number) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null
54
+ const timeOf = (value: unknown) => {
55
+ const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
56
+ return Number.isSafeInteger(at) ? at : null
57
+ }
58
+
59
+ // The address Claude's own commits are authored with.
60
+ export const CLAUDE_EMAIL = 'noreply@anthropic.com'
61
+
62
+ const marking = (marks: AgentSignals) => marks.claude || marks.codex || marks.agents.length > 0 || marks.sessions.length > 0 || marks.tasks.length > 0
63
+
64
+ // The marks an agent left in a commit's message (agent-tasks.ts
65
+ // agentSignals: sessions' and tasks' links, agents' lines and fingerprints),
66
+ // as its history keeps them; null for one none marked.
67
+ export function messageMarks(message: unknown): AgentSignals | null {
68
+ const marks = agentSignals([message], APP_AGENTS)
69
+ return marking(marks) ? marks : null
70
+ }
71
+
72
+ // The marks an agent left on a commit, as the Agents tab tells them: its
73
+ // message's (`marks`, messageMarks), its author an app agent's account, by
74
+ // the agents the app knows now, or Claude's address; null for one none marked.
75
+ export function commitMarks(marks: AgentSignals | null | undefined, { email, account }: Maker): AgentSignals | null {
76
+ const authored = account.login !== null && account.id !== null ? githubAgent(account.login, account.id) : null
77
+ const all = { sessions: marks?.sessions ?? [], tasks: marks?.tasks ?? [], claude: (marks?.claude ?? false) || email === CLAUDE_EMAIL, codex: marks?.codex ?? false,
78
+ agents: APP_AGENTS.flatMap(({ id }) => marks?.agents.includes(id) || authored === id ? [id] : []) }
79
+ return marking(all) ? all : null
80
+ }
81
+ // A commit's marks as its history keeps it (commitMarks).
82
+ export const historyMarks = (commit: HistoryCommit) => commitMarks(commit.marks,
83
+ { name: commit.authorName, email: commit.authorEmail, account: { login: commit.authorLogin, id: commit.authorId ?? null } })
84
+
85
+ const lineCount = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : null
86
+ // The minutes east of UTC an ISO 8601 time is given in: `Z` none.
87
+ const zoneOf = (value: unknown) => {
88
+ const zone = typeof value === 'string' ? /(?:Z|([+-])(\d{2}):?(\d{2}))$/u.exec(value) : null
89
+ return zone ? zone[1] ? (zone[1] === '-' ? -1 : 1) * (Number(zone[2]) * 60 + Number(zone[3])) : 0 : null
90
+ }
91
+
92
+ // A commit as the Commits tab reads it, its subject the message's first
93
+ // line, as `--oneline` shows, its author by name and account, and its
94
+ // co-authors (coauthorsOf, `listed` as GitHub's GraphQL API lists them), and
95
+ // the marks an agent left on it, as the Agents tab reads them from the pages
96
+ // kept (commitMarks); null for one GitHub names no commit id for. With
97
+ // `stats`, the GraphQL API's, its lines and its author's time.
98
+ function historyCommit(sha: unknown, parents: unknown[], message: unknown, author: Maker, committedAt: unknown,
99
+ stats: { additions?: unknown; deletions?: unknown; authoredAt?: unknown } | null = null, listed: Maker[] | null = null): HistoryCommit | null {
100
+ if (typeof sha !== 'string' || !SHA.test(sha)) return null
101
+ const lines = stats ? { additions: lineCount(stats.additions), deletions: lineCount(stats.deletions), authoredAt: timeOf(stats.authoredAt),
102
+ authorZone: zoneOf(stats.authoredAt) } : { additions: null, deletions: null, authoredAt: null, authorZone: null }
103
+ const marks = messageMarks(message)
104
+ return {
105
+ sha,
106
+ parents: parents.filter((parent): parent is string => typeof parent === 'string' && SHA.test(parent)).slice(0, MAX_PARENTS),
107
+ subject: typeof message === 'string' ? message.replaceAll('\r\n', '\n').split('\n', 1)[0]!.trim().slice(0, MAX_SUBJECT) : '',
108
+ authorName: textOf(author.name, MAX_NAME),
109
+ authorEmail: textOf(author.email, MAX_NAME),
110
+ authorLogin: author.account.login,
111
+ authorId: author.account.id,
112
+ ...coauthorFacts(message, listed),
113
+ committedAt: timeOf(committedAt),
114
+ ...lines,
115
+ ...marks ? { marks } : {},
116
+ marked: AGENT_MARKS_READ,
117
+ }
118
+ }
119
+
120
+ type RestCommit = { sha?: unknown; parents?: { sha?: unknown }[]; author?: { login?: unknown; id?: unknown } | null
121
+ commit?: { message?: unknown; author?: { name?: unknown; email?: unknown } | null; committer?: { date?: unknown } | null } }
122
+ // Its co-authors (`listed`) by its trailers, with the accounts of their addresses.
123
+ const restCommit = (item: RestCommit, listed: Maker[] | null = null) => historyCommit(item?.sha, Array.isArray(item?.parents) ? item.parents.map(parent => parent?.sha) : [],
124
+ item?.commit?.message, { name: item?.commit?.author?.name, email: item?.commit?.author?.email, account: accountOf(item?.author) }, item?.commit?.committer?.date,
125
+ null, listed)
126
+
127
+ const repoApi = (fullName: string) => `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
128
+
129
+ // A page of the commits `commit` reaches, newest first as GitHub lists them,
130
+ // those touching `path` alone where it names a directory; `more` where a
131
+ // full page leaves some for the next, one that may be read. GitHub links
132
+ // each's author to its account; with a token, its co-authors by GitHub's older
133
+ // no-reply address too (linkNoreplyUsers).
134
+ export async function repositoryHistory(fullName: string, token: string | null, commit: string, path: string, page: number, fetchImpl: typeof fetch = globalThis.fetch) {
135
+ const params = new URLSearchParams({ sha: commit, per_page: String(HISTORY_PAGE), page: String(page), ...path ? { path } : {} })
136
+ const data = await githubJson(`${repoApi(fullName)}/commits?${params}`, token, fetchImpl)
137
+ if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
138
+ const items = data as RestCommit[]
139
+ const trailers = items.map(item => coauthorTrailers(item?.commit?.message).map(({ name, email }): GraphActor & { name: string } => ({ name, email, user: null })))
140
+ if (token) await linkNoreplyUsers(trailers.flat(), token, fetchImpl)
141
+ return { commits: items.flatMap((item, at) => restCommit(item, token ? trailers[at]!.map(actor => ({ name: actor.name, email: actor.email, account: commitAccount(actor) })) : null) ?? []),
142
+ more: data.length >= HISTORY_PAGE && page < MAX_HISTORY_PAGE }
143
+ }
144
+
145
+ // The commits the repository's branches and tags point at, as GitHub's
146
+ // first page of each lists them, to name commits by.
147
+ export async function repositoryRefTips(fullName: string, token: string | null, fetchImpl: typeof fetch = globalThis.fetch) {
148
+ const tips = (list: unknown): HistoryRef[] => Array.isArray(list) ? list.flatMap((item: { name?: unknown; commit?: { sha?: unknown } }) =>
149
+ typeof item?.name === 'string' && item.name && typeof item.commit?.sha === 'string' && SHA.test(item.commit.sha)
150
+ ? [{ name: item.name.slice(0, MAX_NAME), sha: item.commit.sha }] : []) : []
151
+ const [branches, tags] = await Promise.all(['branches', 'tags'].map(kind => githubJson(`${repoApi(fullName)}/${kind}?per_page=${REF_TIPS}`, token, fetchImpl)))
152
+ return { branches: tips(branches), tags: tips(tags) }
153
+ }
154
+
155
+ // A commit's author and those GitHub lists beside it.
156
+ export const commitActors = (node: GraphCommit | null | undefined) => [node?.author, ...node?.authors?.nodes ?? []]
157
+
158
+ // A tag's target, an annotated tag's own target in turn, through as many as
159
+ // MAX_TAG_DEPTH annotated tags, each object's type naming what it is.
160
+ const MAX_TAG_DEPTH = 4
161
+ const tagTarget = (tags: number): string => `target { __typename oid${tags > 0 ? ` ... on Tag { ${tagTarget(tags - 1)} }` : ''} }`
162
+
163
+ // The branches' tips, a page at a time, each with its commit's date (GitHub
164
+ // orders heads by name alone), and, on the first page, the default branch's
165
+ // tip, wherever its name sorts, and the latest tags.
166
+ const HEADS_QUERY = `query($owner: String!, $name: String!, $after: String, $tags: Int!, $first: Boolean!) {
167
+ repository(owner: $owner, name: $name) {
168
+ defaultBranchRef { name target { oid ... on Commit { committedDate } } }
169
+ branches: refs(refPrefix: "refs/heads/", first: 100, after: $after) {
170
+ pageInfo { hasNextPage endCursor }
171
+ nodes { name target { oid ... on Commit { committedDate } } }
172
+ }
173
+ tags: refs(refPrefix: "refs/tags/", first: $tags, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) @include(if: $first) {
174
+ nodes { name ${tagTarget(MAX_TAG_DEPTH)} }
175
+ }
176
+ }
177
+ }`
178
+
179
+ // As many commits as gluing kept pages on goes to, in as many rounds of
180
+ // looking them up, each looking up as many commits as tops.
181
+ export const HISTORY_GLUED = 2000
182
+ const MAX_GLUE_ROUNDS = 20
183
+ const GLUE_LOOKUP = 50
184
+
185
+ // The pages of a repository's history kept, each by the commit it starts at
186
+ // (its top): the first page of the history from that commit, which never
187
+ // changes.
188
+ export interface HistoryPages {
189
+ read(tops: string[]): Promise<Map<string, HistoryCommit[]>>
190
+ // The pages kept of `first`; where none is, those of `then`, in one read
191
+ // (`first` whether they are `first`'s). Without it, both are read together.
192
+ readFirst?(first: string[], then: string[]): Promise<{ first: boolean; pages: Map<string, HistoryCommit[]> }>
193
+ write(top: string, commits: HistoryCommit[]): Promise<void>
194
+ }
195
+
196
+ // What a commit is read by in GraphQL.
197
+ export const COMMIT_FRAGMENT = `fragment historyCommit on Commit { oid message committedDate additions deletions author { name email date user { login databaseId } }
198
+ authors(first: ${MAX_COAUTHORS + 1}) { nodes { name email user { login databaseId } } } parents(first: ${MAX_PARENTS}) { nodes { oid } } }`
199
+ // A query's variables `$<prefix><n>` of `type`, as many as `count`.
200
+ export const variablesOf = (prefix: string, count: number, type: string) => Array.from({ length: count }, (_, at) => `$${prefix}${at}: ${type}`).join(', ')
201
+
202
+ // The latest commits of each tip, by its id, `b<n>` the `n`th's.
203
+ const historiesQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('o', count, 'GitObjectID!')}) {
204
+ repository(owner: $owner, name: $name) {
205
+ ${Array.from({ length: count }, (_, at) => `b${at}: object(oid: $o${at}) { ... on Commit { history(first: ${ALL_DEPTH}) { pageInfo { hasNextPage } nodes { ...historyCommit } } } }`).join('\n ')}
206
+ }
207
+ }
208
+ ${COMMIT_FRAGMENT}`
209
+
210
+ export type GraphCommit = { oid?: unknown; message?: unknown; committedDate?: unknown; additions?: unknown; deletions?: unknown
211
+ author?: ({ name?: unknown; date?: unknown } & GraphActor) | null; authors?: { nodes?: (({ name?: unknown } & GraphActor) | null)[] } | null
212
+ parents?: { nodes?: { oid?: unknown }[] } }
213
+ type GraphTarget = { __typename?: unknown; oid?: unknown; committedDate?: unknown; target?: GraphTarget | null }
214
+ type GraphRef = { name?: unknown; target?: GraphTarget | null }
215
+ type HeadsPage = { repository?: { defaultBranchRef?: GraphRef | null
216
+ branches?: { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: GraphRef[] }; tags?: { nodes?: GraphRef[] } } | null }
217
+ type Histories = { repository?: Record<string, { history?: { pageInfo?: { hasNextPage?: unknown }; nodes?: GraphCommit[] } } | null> | null }
218
+
219
+ // The repository's branches, as many as MAX_HEAD_PAGES of them list, each
220
+ // with its tip's time, its default branch and latest tags (an annotated tag
221
+ // by the commit it tags), and whether any branch is left out.
222
+ async function repositoryHeads(owner: string, name: string, token: string, fetchImpl: typeof fetch) {
223
+ const heads: (HistoryRef & { at: number | null })[] = []
224
+ let first: HeadsPage['repository'] = null
225
+ let after: string | null = null
226
+ for (let page = 0; page < MAX_HEAD_PAGES; page++) {
227
+ const data = await githubGraphql(HEADS_QUERY, { owner, name, after, tags: ALL_TAGS, first: page === 0 }, token, fetchImpl) as HeadsPage
228
+ const repository = data?.repository
229
+ if (!repository) throw new GithubApiError(404, 'github-not-found')
230
+ first ??= repository
231
+ for (const ref of repository.branches?.nodes ?? []) {
232
+ const sha = ref?.target?.oid
233
+ if (typeof ref?.name === 'string' && ref.name && typeof sha === 'string' && SHA.test(sha)) heads.push({ name: ref.name.slice(0, MAX_NAME), sha, at: timeOf(ref.target?.committedDate) })
234
+ }
235
+ const next = repository.branches?.pageInfo
236
+ if (next?.hasNextPage !== true || typeof next.endCursor !== 'string') return { heads, repository: first, cut: false }
237
+ after = next.endCursor
238
+ }
239
+ return { heads, repository: first, cut: true }
240
+ }
241
+
242
+ // Histories read in a GraphQL query, and queries at once: each counts its
243
+ // commits' lines, which GitHub works out as it answers, stopping a query
244
+ // past ten seconds.
245
+ const PAGES_PER_QUERY = 5
246
+ const PAGES_PARALLEL = 5
247
+
248
+ // The first page of the history from each of `tops`, by its top, each told
249
+ // to `keep` as it is read, so those read stay kept where another fails: with
250
+ // a token, in GraphQL queries of PAGES_PER_QUERY (inBatches); without, a
251
+ // REST list each.
252
+ async function readPages(fullName: string, token: string | null, tops: string[], fetchImpl: typeof fetch,
253
+ keep: (top: string, commits: HistoryCommit[]) => Promise<void>) {
254
+ const read = new Map<string, HistoryCommit[]>()
255
+ const got = (top: string, commits: HistoryCommit[]) => { read.set(top, commits); return keep(top, commits) }
256
+ if (!token) {
257
+ await Promise.all(tops.map(async top => got(top, (await repositoryHistory(fullName, null, top, '', 1, fetchImpl)).commits)))
258
+ return read
259
+ }
260
+ const [owner, name] = fullName.split('/') as [string, string]
261
+ await inBatches(tops, PAGES_PER_QUERY, PAGES_PARALLEL, async batch => {
262
+ const data = await githubGraphql(historiesQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map((sha, at) => [`o${at}`, sha])) }, token, fetchImpl) as Histories
263
+ const nodes = batch.map((_, at) => data?.repository?.[`b${at}`]?.history?.nodes ?? [])
264
+ await linkNoreplyUsers(nodes.flat().flatMap(commitActors), token, fetchImpl)
265
+ await Promise.all(batch.map((top, at) => got(top, nodes[at]!.flatMap(node => graphCommit(node) ?? []))))
266
+ })
267
+ return read
268
+ }
269
+
270
+ // Its authors past the first, the git author, GitHub lists from its
271
+ // `Co-authored-by:` trailers.
272
+ export const graphCommit = (node: GraphCommit | null | undefined) => historyCommit(node?.oid, (node?.parents?.nodes ?? []).map(parent => parent?.oid),
273
+ node?.message, { name: node?.author?.name, email: node?.author?.email, account: commitAccount(node?.author) }, node?.committedDate,
274
+ { additions: node?.additions, deletions: node?.deletions, authoredAt: node?.author?.date },
275
+ Array.isArray(node?.authors?.nodes) ? node.authors.nodes.slice(1).map(actor => ({ name: actor?.name, email: actor?.email, account: commitAccount(actor) })) : null)
276
+
277
+ // The history from each of `starts`, a page of each, kept (`pages`), or read
278
+ // and then kept by its top; glued on to it, the pages kept that start at a
279
+ // parent of a commit listed not listed, each adding commits none listed has,
280
+ // and, where any of those has none kept and readable, at a commit listed, the
281
+ // oldest first (where each goes on furthest; a page at a commit listed lists
282
+ // mostly commits listed already), GLUE_LOOKUP of each looked up at a time, in
283
+ // one read where none of those parents' is kept, else two, and
284
+ // those that start where those go on, till HISTORY_GLUED are listed. The
285
+ // pages from `starts` are listed whole, however many that is (all branches'
286
+ // first, as many as ALL_BRANCHES of them): the cap is gluing's. Each commit
287
+ // once, whether any goes on past those listed (`more`), and how many pages
288
+ // were read of GitHub (`read`).
289
+ export async function historyFrom(fullName: string, token: string | null, starts: string[], pages: HistoryPages, fetchImpl: typeof fetch = globalThis.fetch) {
290
+ const kept = await pages.read(starts)
291
+ const unread = starts.filter(top => !kept.has(top))
292
+ // Kept only as GitHub listed it from its top: none for a commit it named nothing of.
293
+ const keep = (top: string, commits: HistoryCommit[]) => commits[0]?.sha === top ? pages.write(top, commits) : Promise.resolve()
294
+ const read = unread.length > 0 ? await readPages(fullName, token, unread, fetchImpl, keep) : new Map<string, HistoryCommit[]>()
295
+ const listed = new Map<string, HistoryCommit>()
296
+ const add = (commits: HistoryCommit[]) => { for (const commit of commits) if (!listed.has(commit.sha)) listed.set(commit.sha, commit) }
297
+ for (const top of starts) add(kept.get(top) ?? read.get(top) ?? [])
298
+ const looked = new Set(starts)
299
+ for (let round = 0; round < MAX_GLUE_ROUNDS && listed.size < HISTORY_GLUED; round++) {
300
+ const parents = [...new Set([...listed.values()].flatMap(commit => commit.parents.filter(parent => !listed.has(parent) && !looked.has(parent))))].slice(0, GLUE_LOOKUP)
301
+ const within = [...listed.keys()].toReversed().filter(sha => !looked.has(sha)).slice(0, GLUE_LOOKUP)
302
+ if (parents.length === 0 && within.length === 0) break
303
+ // Without readFirst, GLUE_LOOKUP of both at once, the parents first.
304
+ const asked = pages.readFirst ? null : [...parents, ...within].slice(0, GLUE_LOOKUP)
305
+ let { first, pages: glued } = asked ? { first: false, pages: await pages.read(asked) } : await pages.readFirst!(parents, within)
306
+ // A parent with no page kept, or none readable (one GitHub's REST API
307
+ // listed, read with GraphQL), its line going on only by a page at a
308
+ // commit listed: those read too, apart, the others' lines notwithstanding.
309
+ if (first && glued.size < parents.length && within.length > 0) {
310
+ glued = new Map([...glued, ...await pages.read(within)])
311
+ first = false
312
+ }
313
+ // Those at commits listed, unread where a parent's answered, are looked up again.
314
+ for (const sha of asked ?? (first ? parents : [...parents, ...within])) looked.add(sha)
315
+ for (const commit of [...glued.values()].flat()) {
316
+ if (listed.size >= HISTORY_GLUED) break
317
+ if (!listed.has(commit.sha)) listed.set(commit.sha, commit)
318
+ }
319
+ }
320
+ const commits = [...listed.values()]
321
+ return { commits, more: commits.some(commit => commit.parents.some(parent => !listed.has(parent))), read: unread.length }
322
+ }
323
+
324
+ export type AllHeads = { heads: (HistoryRef & { at: number | null })[]; defaultBranch: string | null; tags: HistoryRef[]; cut: boolean }
325
+
326
+ // `--all`'s branches: the repository's, as many as MAX_HEAD_PAGES of them
327
+ // list, each with its tip's time, its default branch among them wherever its
328
+ // name sorts; its latest tags, each by the commit it tags; and whether
329
+ // branches past those listed are left out (`cut`).
330
+ export async function repositoryAllHeads(fullName: string, token: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<AllHeads> {
331
+ const [owner, name] = fullName.split('/') as [string, string]
332
+ const { heads, repository, cut } = await repositoryHeads(owner, name, token, fetchImpl)
333
+ const defaultBranch = textOf(repository?.defaultBranchRef?.name, MAX_NAME)
334
+ // The default branch, where its name sorts past the heads read.
335
+ const defaultTip = repository?.defaultBranchRef?.target?.oid
336
+ if (defaultBranch && typeof defaultTip === 'string' && SHA.test(defaultTip) && !heads.some(head => head.name === defaultBranch)) {
337
+ heads.push({ name: defaultBranch, sha: defaultTip, at: timeOf(repository?.defaultBranchRef?.target?.committedDate) })
338
+ }
339
+ // A tag by the commit it tags, through the annotated tags between; one
340
+ // ending at no commit names none.
341
+ const tags = (repository?.tags?.nodes ?? []).flatMap(ref => {
342
+ let target = ref?.target
343
+ while (target?.__typename === 'Tag') target = target.target
344
+ const sha = target?.__typename === 'Commit' ? target.oid : null
345
+ return typeof ref?.name === 'string' && typeof sha === 'string' && SHA.test(sha) ? [{ name: ref.name.slice(0, MAX_NAME), sha }] : []
346
+ })
347
+ return { heads, defaultBranch, tags, cut }
348
+ }
349
+
350
+ // `--all`: the latest commits of the most recently committed to of `heads`
351
+ // (repositoryAllHeads), the default branch's among them, each commit once
352
+ // (historyFrom); the branches naming them and the latest tags; the branches
353
+ // not read, newest first, whose tips none of those is (`unread`), where
354
+ // reading on goes; whether branches past those listed are left out, never to
355
+ // be read (`cut`); and whether any is left out (`more`).
356
+ export async function repositoryAllHistory(fullName: string, token: string, { heads, defaultBranch, tags, cut }: AllHeads, pages: HistoryPages,
357
+ fetchImpl: typeof fetch = globalThis.fetch) {
358
+ const newest = heads.toSorted((a, b) => Number(b.name === defaultBranch) - Number(a.name === defaultBranch) || (b.at ?? 0) - (a.at ?? 0))
359
+ const shown = newest.slice(0, ALL_BRANCHES)
360
+ const read = await historyFrom(fullName, token, [...new Set(shown.map(head => head.sha))], pages, fetchImpl)
361
+ const listed = new Set(read.commits.map(commit => commit.sha))
362
+ const more = cut || heads.length > shown.length || read.more
363
+ // Every branch naming a commit listed, those not read among them.
364
+ const branches = heads.flatMap(({ name: branch, sha }) => listed.has(sha) ? [{ name: branch, sha }] : [])
365
+ const unread = newest.slice(ALL_BRANCHES).filter(head => !listed.has(head.sha))
366
+ return { commits: read.commits, branches, tags, defaultBranch, unread, cut, more }
367
+ }
368
+
369
+ const chunked = <T>(items: T[], size: number) => Array.from({ length: Math.ceil(items.length / size) }, (_, at) => items.slice(at * size, (at + 1) * size))
370
+ // GitHub stopping a query past its time: in its answer's errors, or as a 502
371
+ // or 504 of its own.
372
+ const SLOW = new Set(['github-timeout', 'github-status-502', 'github-status-504'])
373
+ // `run` for `items` in batches of `size`, as many at once as `parallel`; a
374
+ // batch GitHub stops past its time run again in halves, down to one alone,
375
+ // so that one too slow to read fails alone, the others read. The halves
376
+ // wait their turn with the rest, and as many batches are halved as run at
377
+ // once twice over at most, GitHub slow throughout past that. Where one
378
+ // fails, none more is run, those running done before its error is thrown,
379
+ // so that what they read is kept.
380
+ export async function inBatches<T>(items: T[], size: number, parallel: number, run: (batch: T[]) => Promise<void>) {
381
+ const failed: unknown[] = []
382
+ const waiting: (() => void)[] = []
383
+ let halved = 0, running = 0
384
+ const turn = () => {
385
+ if (running < parallel) { running++; return Promise.resolve() }
386
+ return new Promise<void>(resolve => { waiting.push(resolve) })
387
+ }
388
+ // A turn done, handed to the batch waiting longest.
389
+ const done = () => {
390
+ const next = waiting.shift()
391
+ if (next) next()
392
+ else running--
393
+ }
394
+ const attempt = async (batch: T[]): Promise<void> => {
395
+ await turn()
396
+ try {
397
+ if (failed.length === 0) await run(batch)
398
+ return
399
+ } catch (err) {
400
+ if (batch.length === 1 || halved >= 2 * parallel || !(err instanceof GithubApiError && SLOW.has(err.message))) { failed.push(err); return }
401
+ halved++
402
+ } finally { done() }
403
+ const half = Math.ceil(batch.length / 2)
404
+ await Promise.all([attempt(batch.slice(0, half)), attempt(batch.slice(half))])
405
+ }
406
+ await Promise.all(chunked(items, size).map(attempt))
407
+ if (failed.length > 0) throw failed[0]
408
+ }
409
+
410
+ // The open pull requests most recently updated, each by the commit its head
411
+ // is at, to name commits by.
412
+ const OPEN_PULLS = 100
413
+ const OPEN_PULLS_QUERY = `query($owner: String!, $name: String!) {
414
+ repository(owner: $owner, name: $name) {
415
+ pullRequests(states: OPEN, first: ${OPEN_PULLS}, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { number headRefOid } }
416
+ }
417
+ }`
418
+ export async function openPullHeads(fullName: string, token: string, fetchImpl: typeof fetch = globalThis.fetch) {
419
+ const [owner, name] = fullName.split('/') as [string, string]
420
+ const data = await githubGraphql(OPEN_PULLS_QUERY, { owner, name }, token, fetchImpl) as
421
+ { repository?: { pullRequests?: { nodes?: ({ number?: unknown; headRefOid?: unknown } | null)[] } } | null }
422
+ return { pulls: (data?.repository?.pullRequests?.nodes ?? []).flatMap(node => Number.isSafeInteger(node?.number) && (node!.number as number) > 0
423
+ && typeof node!.headRefOid === 'string' && SHA.test(node!.headRefOid) ? [{ number: node!.number as number, sha: node!.headRefOid }] : []) }
424
+ }
425
+
426
+ // Where `--all` reads on from, as a request names them (`from`): as many
427
+ // commit ids, each once, as branches are read at first; null for none, or
428
+ // for one that isn't a commit id.
429
+ export function historyStarts(from: string): string[] | null {
430
+ const starts = [...new Set(from.split(','))]
431
+ return starts.length <= ALL_BRANCHES && starts.every(sha => SHA.test(sha)) ? starts : null
432
+ }
@@ -0,0 +1,195 @@
1
+ // The issues and pull requests the code viewer's Issues and Pull requests
2
+ // tabs show (GET /api/admin/repositories/issues): those open, or those
3
+ // closed (a pull request's merged among them), the most recently updated
4
+ // first, a page at a time, each as one tile has room for, with how many are
5
+ // open and closed. A pull request's tile names its branches, its size, its
6
+ // review and its checks, which GraphQL alone gives, so it needs a token; an
7
+ // open one's, and its popup's, whether it changes a lockfile whose new
8
+ // packages are told (pull-dependencies.ts touchesLockfile), and its reviews
9
+ // (pull-reviews.ts), each reviewer's; and an open one's base's and head's
10
+ // commits, kept with its page but never told, by which what its lockfiles
11
+ // add is read (http.ts teamRepositoryDependencies). A nonpublic repository's
12
+ // are read with its GitHub App's installation, which reads pull requests only
13
+ // with their own permission (doc/code-viewer.md).
14
+ import { GithubApiError } from './github-app.ts'
15
+ import { touchesLockfile } from './pull-dependencies.ts'
16
+ import { ACTOR, type GraphAccount, type GraphqlError, actorOf, githubGraphql } from './repository-history.ts'
17
+ import { ITEM_THREADS, type ReviewNode, TILE_THREADS, pullReviews, reviewFields, reviewsRefused } from './pull-reviews.ts'
18
+
19
+ export const ISSUES_PAGE = 50
20
+ // What a kept page or item was read as: kept ones read before differ.
21
+ export const ISSUES_READ_VERSION = 4
22
+ const MAX_LABELS = 20
23
+ const MAX_TITLE = 1000
24
+ const MAX_NAME = 256
25
+ const COLOR = /^[a-f\d]{6}$/iu
26
+ const SHA = /^[a-f\d]{40}$/u
27
+ // A page's cursor as GitHub gives it, to read on from: opaque, base64 or
28
+ // base64url as it is now (`…rOrK_u7Q==`), passed back to GitHub alone, so any
29
+ // printable one is, but none with spaces or control characters.
30
+ export const ISSUES_CURSOR = /^[!-~]{1,256}$/u
31
+
32
+ export type IssuesKind = 'issues' | 'pulls'
33
+ export type IssuesState = 'open' | 'closed'
34
+
35
+ const shared = `number title createdAt updatedAt closedAt author { ${ACTOR} } comments { totalCount } labels(first: ${MAX_LABELS}) { totalCount nodes { name color } }`
36
+ const fields = {
37
+ issues: `${shared} state stateReason`,
38
+ pulls: `${shared} state isDraft mergedAt reviewDecision additions deletions headRefName baseRefName
39
+ commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`,
40
+ }
41
+ // The files a pull request changes, as many as LISTED_FILES, as an open one's
42
+ // tile and its popup tell whether it changes a lockfile by them.
43
+ const LISTED_FILES = 100
44
+ const files = `files(first: ${LISTED_FILES}) { totalCount nodes { path } }`
45
+ const connection = { issues: 'issues', pulls: 'pullRequests' }
46
+ const states = { issues: { open: '[OPEN]', closed: '[CLOSED]' }, pulls: { open: '[OPEN]', closed: '[CLOSED, MERGED]' } }
47
+
48
+ // A page of `kind`, those in `state`, after the page `after` ends, and how
49
+ // many of each state there are; open pull requests with their commits and
50
+ // their reviews.
51
+ const pageQuery = (kind: IssuesKind, state: IssuesState) => `query($owner: String!, $name: String!, $after: String) {
52
+ repository(owner: $owner, name: $name) {
53
+ open: ${connection[kind]}(states: ${states[kind].open}) { totalCount }
54
+ closed: ${connection[kind]}(states: ${states[kind].closed}) { totalCount }
55
+ page: ${connection[kind]}(states: ${states[kind][state]}, first: ${ISSUES_PAGE}, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
56
+ pageInfo { hasNextPage endCursor }
57
+ nodes { ${fields[kind]}${kind === 'pulls' && state === 'open' ? ` baseRefOid headRefOid ${files} ${reviewFields(TILE_THREADS, false)}` : ''} }
58
+ }
59
+ }
60
+ }`
61
+
62
+ type Node = ReviewNode & { number?: unknown; title?: unknown; createdAt?: unknown; updatedAt?: unknown; closedAt?: unknown; mergedAt?: unknown; author?: GraphAccount
63
+ comments?: { totalCount?: unknown }; labels?: { totalCount?: unknown; nodes?: { name?: unknown; color?: unknown }[] }; state?: unknown; stateReason?: unknown
64
+ isDraft?: unknown; reviewDecision?: unknown; additions?: unknown; deletions?: unknown; headRefName?: unknown; baseRefName?: unknown; baseRefOid?: unknown
65
+ commits?: { nodes?: { commit?: { statusCheckRollup?: { state?: unknown } | null } | null }[] }
66
+ files?: { totalCount?: unknown; nodes?: ({ path?: unknown } | null)[] } | null }
67
+ type Page = { repository?: { open?: { totalCount?: unknown }; closed?: { totalCount?: unknown }
68
+ page?: { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: (Node | null)[] } } | null }
69
+
70
+ const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
71
+ const timeOf = (value: unknown) => {
72
+ const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
73
+ return Number.isSafeInteger(at) ? at : null
74
+ }
75
+ const nameOf = (value: unknown, max = MAX_NAME) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null
76
+ const REVIEWS: Record<string, string> = { APPROVED: 'approved', CHANGES_REQUESTED: 'changes', REVIEW_REQUIRED: 'required' }
77
+ const CHECKS: Record<string, string> = { SUCCESS: 'success', FAILURE: 'failure', ERROR: 'failure', PENDING: 'pending', EXPECTED: 'pending' }
78
+ const REASONS: Record<string, string> = { COMPLETED: 'completed', NOT_PLANNED: 'not-planned', DUPLICATE: 'duplicate' }
79
+
80
+ // An issue or pull request as a tile shows it; null for one GitHub names no
81
+ // number for.
82
+ function item(kind: IssuesKind, node: Node | null) {
83
+ if (!node || !Number.isSafeInteger(node.number) || (node.number as number) <= 0) return null
84
+ const author = actorOf(node.author)
85
+ const labels = (node.labels?.nodes ?? []).flatMap(label => {
86
+ const name = nameOf(label?.name)
87
+ return name ? [{ name, color: typeof label?.color === 'string' && COLOR.test(label.color) ? label.color.toLowerCase() : null }] : []
88
+ })
89
+ const common = { number: node.number as number, title: nameOf(node.title, MAX_TITLE) ?? '', author: author.login, authorId: author.id, createdAt: timeOf(node.createdAt), updatedAt: timeOf(node.updatedAt),
90
+ closedAt: timeOf(node.closedAt), comments: count(node.comments?.totalCount), labels, moreLabels: Math.max(0, count(node.labels?.totalCount) - labels.length) }
91
+ if (kind === 'issues') {
92
+ return { ...common, state: node.state === 'OPEN' ? 'open' : 'closed', reason: REASONS[String(node.stateReason)] ?? null }
93
+ }
94
+ const checks = node.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state
95
+ const listed = node.files?.nodes
96
+ const paths = Array.isArray(listed) ? listed.flatMap(file => typeof file?.path === 'string' ? [file.path] : []) : null
97
+ return { ...common, state: node.state === 'MERGED' ? 'merged' : node.state === 'OPEN' ? 'open' : 'closed', draft: node.isDraft === true, mergedAt: timeOf(node.mergedAt),
98
+ review: REVIEWS[String(node.reviewDecision)] ?? null, checks: CHECKS[String(checks)] ?? null, additions: count(node.additions), deletions: count(node.deletions),
99
+ head: nameOf(node.headRefName), base: nameOf(node.baseRefName),
100
+ ...paths ? { touchesLockfile: touchesLockfile(paths, count(node.files?.totalCount)) } : {} }
101
+ }
102
+
103
+ // A pull request's checks, which a GitHub App reads only with the Checks and
104
+ // Commit statuses permissions, refused: its tile then shows none; or its
105
+ // files, whose lockfiles' new packages it then tells none of. Any other
106
+ // field refused, the list is, as one without Pull requests: read is.
107
+ const checksRefused = (error: GraphqlError) => error?.type === 'FORBIDDEN' && Array.isArray(error.path)
108
+ && (error.path.includes('statusCheckRollup') || error.path.includes('files'))
109
+ const partRefused = (error: GraphqlError) => checksRefused(error) || reviewsRefused(error)
110
+
111
+ // One issue or pull request, as its popup shows it (GET
112
+ // /api/admin/repositories/item): as its tile does, with its description, its
113
+ // Markdown as written (never GitHub's HTML of it); an issue's pull requests closing
114
+ // it, a pull request's issues it closes, its commits and files changed, and
115
+ // who merged it. Its state, of a different type each, named apart. A
116
+ // description longer than MAX_BODY is shortened (`bodyShortened`), and the
117
+ // linked past the first LINKED counted (`moreLinked`), the rest on GitHub.
118
+ const MAX_BODY = 20_000
119
+ const LINKED = 10
120
+ const ITEM_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
121
+ repository(owner: $owner, name: $name) {
122
+ item: issueOrPullRequest(number: $number) {
123
+ __typename
124
+ ... on Issue { ${shared} issueState: state stateReason body
125
+ closedByPullRequestsReferences(first: ${LINKED}, includeClosedPrs: true) { totalCount nodes { number title pullState: state isDraft } } }
126
+ ... on PullRequest { ${shared} pullState: state isDraft mergedAt reviewDecision additions deletions headRefName baseRefName body changedFiles ${files}
127
+ commits(last: 1) { totalCount nodes { commit { statusCheckRollup { state } } } } mergedBy { ${ACTOR} }
128
+ closingIssuesReferences(first: ${LINKED}) { totalCount nodes { number title issueState: state stateReason } }
129
+ ${reviewFields(ITEM_THREADS, true)} }
130
+ }
131
+ }
132
+ }`
133
+ type Linked = { number?: unknown; title?: unknown; issueState?: unknown; pullState?: unknown; stateReason?: unknown; isDraft?: unknown } | null
134
+ type ItemNode = Node & { __typename?: unknown; issueState?: unknown; pullState?: unknown; body?: unknown; changedFiles?: unknown
135
+ commits?: Node['commits'] & { totalCount?: unknown }; mergedBy?: GraphAccount
136
+ closedByPullRequestsReferences?: Links; closingIssuesReferences?: Links }
137
+ type Links = { totalCount?: unknown; nodes?: Linked[] } | null
138
+
139
+ // The issues or pull requests an item names, each by its number, title and
140
+ // state, as `kind`'s tile names one.
141
+ const linked = (kind: IssuesKind, nodes: Linked[] | undefined) => (nodes ?? []).flatMap(node => {
142
+ const tile = item(kind, node ? { number: node.number, title: node.title, state: node.issueState ?? node.pullState, stateReason: node.stateReason, isDraft: node.isDraft } : null)
143
+ return tile ? [{ number: tile.number, title: tile.title, state: tile.state, ...'reason' in tile ? { reason: tile.reason } : { draft: tile.draft } }] : []
144
+ })
145
+
146
+ // The issue or pull request numbered `number`, its `kind` with it.
147
+ export async function repositoryItem(fullName: string, token: string, number: number, fetchImpl: typeof fetch = globalThis.fetch) {
148
+ const [owner, name] = fullName.split('/') as [string, string]
149
+ const data = await githubGraphql(ITEM_QUERY, { owner, name, number }, token, fetchImpl, partRefused) as { repository?: { item?: ItemNode | null } | null }
150
+ const node = data?.repository?.item
151
+ const kind: IssuesKind | null = node?.__typename === 'Issue' ? 'issues' : node?.__typename === 'PullRequest' ? 'pulls' : null
152
+ const tile = kind && node ? item(kind, { ...node, state: kind === 'issues' ? node.issueState : node.pullState }) : null
153
+ if (!kind || !node || !tile) throw new GithubApiError(404, 'github-not-found')
154
+ // As written, but for blank lines before it and spaces after: a first
155
+ // line's indent, an indented code block's, kept.
156
+ const written = typeof node.body === 'string' ? node.body.replace(/^(?:[ \t]*\r?\n)+/u, '').trimEnd() : ''
157
+ const links = kind === 'issues' ? node.closedByPullRequestsReferences : node.closingIssuesReferences
158
+ const named = linked(kind === 'issues' ? 'pulls' : 'issues', links?.nodes)
159
+ const described = { body: written.slice(0, MAX_BODY), bodyShortened: written.length > MAX_BODY, moreLinked: Math.max(0, count(links?.totalCount) - named.length) }
160
+ const merger = actorOf(node.mergedBy)
161
+ return kind === 'issues' ? { kind, ...tile, ...described, closedBy: named }
162
+ : { kind, ...tile, ...described, closes: named, commits: count(node.commits?.totalCount), files: count(node.changedFiles),
163
+ mergedBy: merger.login, mergedById: merger.id, reviews: pullReviews(node, tile.author, true) }
164
+ }
165
+
166
+ // An open pull request's base's and head's commits, by its number, as GitHub
167
+ // has them now: those its REST API names `base.sha` and `head.sha`
168
+ // (pull-dependencies.ts pullCommits). None where either isn't a commit's id.
169
+ const commitsOf = (number: number, { baseRefOid: base, headRefOid: head }: Node) =>
170
+ typeof base === 'string' && SHA.test(base) && typeof head === 'string' && SHA.test(head) ? [{ number, base, head }] : []
171
+
172
+ // A page of the repository's issues or pull requests in `state`, after the
173
+ // page `after` ended (null for the first): its `items`, how many are open and
174
+ // closed (`counts`), and, where more follow, the cursor reading them
175
+ // (`after`); open pull requests' commits (`commits`, commitsOf) too, which
176
+ // the code viewer is never told (http.ts handleRepositoryIssues).
177
+ export async function repositoryIssues(fullName: string, token: string, kind: IssuesKind, state: IssuesState, after: string | null, fetchImpl: typeof fetch = globalThis.fetch) {
178
+ const [owner, name] = fullName.split('/') as [string, string]
179
+ const data = await githubGraphql(pageQuery(kind, state), { owner, name, after }, token, fetchImpl, partRefused) as Page
180
+ const repository = data?.repository
181
+ if (!repository) throw new GithubApiError(404, 'github-not-found')
182
+ const info = repository.page?.pageInfo
183
+ const next = info?.hasNextPage === true && typeof info.endCursor === 'string' && ISSUES_CURSOR.test(info.endCursor) ? info.endCursor : null
184
+ const open = kind === 'pulls' && state === 'open'
185
+ const tiles = (repository.page?.nodes ?? []).flatMap(node => {
186
+ const tile = item(kind, node)
187
+ return tile && node ? [{ node, tile }] : []
188
+ })
189
+ return {
190
+ items: tiles.map(({ node, tile }) => open ? { ...tile, reviews: pullReviews(node, tile.author) } : tile),
191
+ counts: { open: count(repository.open?.totalCount), closed: count(repository.closed?.totalCount) },
192
+ more: next !== null, after: next,
193
+ ...open ? { commits: tiles.flatMap(({ node, tile }) => commitsOf(tile.number, node)) } : {},
194
+ }
195
+ }