@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,448 @@
1
+ // What the code viewer's Agents tab reconstructs agents' sessions
2
+ // from (GET /api/admin/repositories/agents, common/managed/agent-tasks.ts):
3
+ // a page of the repository's pull requests, the most recently updated first,
4
+ // as kept for the Pulse too (repository-pulls.ts readPulls), those past
5
+ // them searched for, and of its default branch's commits, the newest first,
6
+ // those no pull request merged, read from its history as the Commits tab
7
+ // keeps it, the
8
+ // marks an agent left kept with each (repository-history.ts commitMarks),
9
+ // whether a pull request brought one looked up for those marked alone, and
10
+ // kept by the commit; on the first page, its latest issues and the newest of
11
+ // its agents' branches (GitHub lists branches by name alone, so every one an
12
+ // agent's prefix names is read, as many as fifty pages of each, and the newest
13
+ // kept). Each artifact's own texts (never comments: an agent links its
14
+ // session in a pull request's body and its commits' messages) are read here
15
+ // for the marks an agent leaves, and a pull request's labels for one naming
16
+ // an agent, and those alone are answered, never the texts. GraphQL alone
17
+ // gives them, so it needs a token.
18
+ import { type AgentAuthor, type AgentBranch, type AgentCommit, type AgentIssue, type AgentPull, type AgentSignals, type AppAgent, agentSignals } from '../common/managed/agent-tasks.ts'
19
+ import { GITHUB_AGENTS, type GithubAgentAccount, type GithubAgentId, githubAgent } from '../common/managed/github-agents.ts'
20
+ import { AGENT_BRANCH_PREFIXES, APP_AGENTS } from './app-agents.ts'
21
+ import type { GraphqlError } from './github-graphql.ts'
22
+ import { ACTOR, ALL_BRANCHES, type HistoryCommit, type HistoryPages, type NamedAccount, actorOf, commitAccount, commitMarks, githubGraphql, historyFrom, historyMarks,
23
+ inBatches, linkNoreplyUsers, messageMarks, variablesOf } from './repository-history.ts'
24
+ import type { IssueList, PullList } from './repository-pulls.ts'
25
+
26
+ export const AGENT_PULLS_PAGE = 50
27
+ export const AGENT_COMMITS_PAGE = 100
28
+ const AGENT_ISSUES = 100
29
+ const AGENT_BRANCHES = 500
30
+ const CLOSES = 5
31
+ // A pull request's labels read, for one naming an agent: GitHub's most a
32
+ // page.
33
+ const LABELS = 100
34
+ const MAX_TITLE = 1000
35
+ const MAX_NAME = 256
36
+ const LOGIN = /^[A-Za-z\d][A-Za-z\d-]{0,38}(?:\[bot\])?$/u
37
+ const SHA = /^[a-f\d]{40}$/u
38
+ // Commits whether a pull request brought each is looked up of, in a query,
39
+ // and queries at once.
40
+ const PULLED_COMMITS = 100
41
+ const PULLED_PARALLEL = 4
42
+
43
+ // The app agents' accounts' no-reply addresses, by each login of theirs.
44
+ const AGENT_EMAILS = APP_AGENTS.flatMap(({ id }) => GITHUB_AGENTS[id as GithubAgentId].accounts
45
+ .flatMap(({ id: account, logins }: GithubAgentAccount) => account === undefined ? [] : logins.map(login => `${account}+${login}@users.noreply.github.com`)))
46
+
47
+ // Branches' tip commits, `c<n>` the `n`th's: each's message and author.
48
+ const tipsQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('o', count, 'GitObjectID!')}) {
49
+ repository(owner: $owner, name: $name) {
50
+ ${Array.from({ length: count }, (_, at) => `c${at}: object(oid: $o${at}) { ... on Commit { message author { email user { login databaseId } } } }`).join('\n ')}
51
+ }
52
+ }`
53
+ // Tips looked up a query, and queries at once.
54
+ const TIPS = 100
55
+ const TIPS_PARALLEL = 4
56
+ // An agent's branches, a page at a time, as GitHub lists them by name, where
57
+ // the repository's branches as kept stop short of them (`cut`): each its tip
58
+ // and when that was committed; as many pages of each as MAX_BRANCH_PAGES.
59
+ const BRANCHES_QUERY = `query($owner: String!, $name: String!, $prefix: String!, $after: String) {
60
+ repository(owner: $owner, name: $name) {
61
+ refs(refPrefix: $prefix, first: 100, after: $after) {
62
+ pageInfo { hasNextPage endCursor }
63
+ nodes { prefix name target { oid ... on Commit { committedDate } } }
64
+ }
65
+ }
66
+ }`
67
+ const MAX_BRANCH_PAGES = 50
68
+ // What a commit of the default branch is read by.
69
+ const COMMIT_FIELDS = 'oid messageHeadline message committedDate additions deletions author { email user { login databaseId } } associatedPullRequests(first: 1) { totalCount }'
70
+ // The newest of the commits from the default branch's head the app agents'
71
+ // accounts authored, by their GitHub no-reply addresses, however far back: a
72
+ // bot's commits pushed with no pull request here (as before a repository
73
+ // moved) may lie deeper than pages are read on to.
74
+ const AUTHORED_QUERY = `query($owner: String!, $name: String!, $head: GitObjectID!, $emails: [String!]!) {
75
+ repository(owner: $owner, name: $name) {
76
+ object(oid: $head) { ... on Commit { history(first: ${AGENT_COMMITS_PAGE}, author: { emails: $emails }) { nodes { ${COMMIT_FIELDS} } } } }
77
+ }
78
+ }`
79
+ // What a pull request is read by, for its row (pull): its texts for the
80
+ // marks an agent leaves in them (its body, its last commit's message, its
81
+ // merge commit's), never answered; its commits counted beside (`commits {
82
+ // totalCount }`, as the reads of it ask theirs).
83
+ export const AGENT_PULL_FIELDS = `number title state isDraft createdAt updatedAt author { __typename ${ACTOR} } body headRefName isCrossRepository
84
+ headRepositoryOwner { login } additions deletions last: commits(last: 1) { nodes { commit { message } } } mergeCommit { message }
85
+ closingIssuesReferences(first: ${CLOSES}) { nodes { number } } labels(first: ${LABELS}) { nodes { name } }`
86
+ // What an issue is read by, for its row (issue).
87
+ export const AGENT_ISSUE_FIELDS = `number title state createdAt updatedAt author { __typename ${ACTOR} }`
88
+ // The latest issues, however long ago they were updated.
89
+ const ISSUES_QUERY = `query($owner: String!, $name: String!) {
90
+ repository(owner: $owner, name: $name) {
91
+ issues(first: ${AGENT_ISSUES}, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { ${AGENT_ISSUE_FIELDS} } }
92
+ }
93
+ }`
94
+ // A page of the pull requests a search finds, as GitHub sorts them, on from
95
+ // where `after` says its page before ended.
96
+ const SEARCH_QUERY = `query($query: String!, $after: String) {
97
+ search(type: ISSUE, query: $query, first: ${AGENT_PULLS_PAGE}, after: $after) {
98
+ pageInfo { endCursor } nodes { ... on PullRequest { ${AGENT_PULL_FIELDS} commits { totalCount } } }
99
+ }
100
+ }`
101
+
102
+ type Login = { login?: unknown; databaseId?: unknown } | null | undefined
103
+ export type PullNode = { number?: unknown; title?: unknown; state?: unknown; isDraft?: unknown; createdAt?: unknown; updatedAt?: unknown; author?: Login; body?: unknown
104
+ headRefName?: unknown; isCrossRepository?: unknown; headRepositoryOwner?: Login; additions?: unknown; deletions?: unknown; commits?: { totalCount?: unknown } | null
105
+ last?: { nodes?: ({ commit?: { message?: unknown } | null } | null)[] } | null; mergeCommit?: { message?: unknown } | null
106
+ closingIssuesReferences?: { nodes?: ({ number?: unknown } | null)[] }; labels?: { nodes?: ({ name?: unknown } | null)[] } }
107
+ type GitActor = { email?: unknown; user?: Login }
108
+ type CommitNode = { oid?: unknown; messageHeadline?: unknown; message?: unknown; committedDate?: unknown; additions?: unknown; deletions?: unknown
109
+ author?: GitActor | null; associatedPullRequests?: { totalCount?: unknown } }
110
+ export type IssueNode = { number?: unknown; title?: unknown; state?: unknown; createdAt?: unknown; updatedAt?: unknown; author?: Login }
111
+ type Connection<T> = { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: (T | null)[] }
112
+ // Whether a pull request brought each commit, `c<n>` the `n`th's.
113
+ const pulledQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('o', count, 'GitObjectID!')}) {
114
+ repository(owner: $owner, name: $name) {
115
+ ${Array.from({ length: count }, (_, at) => `c${at}: object(oid: $o${at}) { ... on Commit { associatedPullRequests(first: 1) { totalCount } } }`).join('\n ')}
116
+ }
117
+ }`
118
+ type Pulled = { repository?: Record<string, { associatedPullRequests?: { totalCount?: unknown } } | null> | null }
119
+ type Authored = { repository?: { object?: { history?: Connection<CommitNode> } | null } | null }
120
+ type Issues = { repository?: { issues?: Connection<IssueNode> } | null }
121
+ type Tips = { repository?: Record<string, { message?: unknown; author?: GitActor | null } | null> | null }
122
+ type RefNode = { prefix?: unknown; name?: unknown; target?: { oid?: unknown; committedDate?: unknown } | null }
123
+ type RefsPage = { repository?: { refs?: Connection<RefNode> } | null }
124
+ type Found = { search?: { pageInfo?: { endCursor?: unknown }; nodes?: (PullNode | null)[] } | null } | null
125
+
126
+ const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
127
+ const number = (value: unknown) => Number.isSafeInteger(value) && (value as number) > 0 ? value as number : null
128
+ const timeOf = (value: unknown) => {
129
+ const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
130
+ return Number.isSafeInteger(at) ? at : null
131
+ }
132
+ const nameOf = (value: unknown, max = MAX_NAME) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null
133
+ const loginOf = (author: Login) => typeof author?.login === 'string' && LOGIN.test(author.login) ? author.login : null
134
+ const PULL_STATES: Record<string, AgentPull['state']> = { OPEN: 'open', MERGED: 'merged', CLOSED: 'closed' }
135
+
136
+ // A pull request's row: what it is, and the marks an agent left on it.
137
+ export function pull(node: PullNode | null): AgentPull | null {
138
+ const at = number(node?.number)
139
+ if (!node || !at) return null
140
+ const author = accountOf(node.author)
141
+ return { number: at, title: nameOf(node.title, MAX_TITLE) ?? '', state: PULL_STATES[String(node.state)] ?? 'closed', draft: node.isDraft === true,
142
+ author: loginOf(node.author), authorId: author?.id ?? null, createdAt: timeOf(node.createdAt), updatedAt: timeOf(node.updatedAt), head: nameOf(node.headRefName),
143
+ // A fork's, its owner; one GitHub no longer names, a fork of its own.
144
+ fork: node.isCrossRepository === true ? loginOf(node.headRepositoryOwner) ?? `pull-${at}` : null,
145
+ additions: count(node.additions), deletions: count(node.deletions), commits: count(node.commits?.totalCount),
146
+ closes: (node.closingIssuesReferences?.nodes ?? []).flatMap(closed => number(closed?.number) ?? []),
147
+ signals: agentSignals([node.body, node.last?.nodes?.[0]?.commit?.message, node.mergeCommit?.message], APP_AGENTS, nameOf(node.headRefName),
148
+ author, (node.labels?.nodes ?? []).map(label => label?.name)) }
149
+ }
150
+
151
+ // An account as GitHub names it, by its login and id, both.
152
+ const both = ({ login, id }: NamedAccount): AgentAuthor | null => login !== null && id !== null ? { login, id } : null
153
+ const accountOf = (actor: Login) => both(actorOf(actor))
154
+ // A commit's author: the account GitHub knows by its address, else the one
155
+ // its no-reply address names by id and login, as a bot's, Copilot's or
156
+ // Dependabot's, is told by that alone (repository-history.ts commitAccount).
157
+ const authorOf = (actor: GitActor | null | undefined) => both(commitAccount(actor))
158
+
159
+ // A commit the app agents' accounts authored, as kept: as read, its
160
+ // message's marks (messageMarks) in its place, never the message.
161
+ export type AuthoredCommit = Omit<CommitNode, 'message'> & { marks: AgentSignals | null }
162
+ const authoredOf = ({ message, ...node }: CommitNode): AuthoredCommit => ({ ...node, marks: messageMarks(message) })
163
+
164
+ // A commit to the default branch no pull request merged, where an agent left
165
+ // its marks on it, or Claude authored it (commitMarks).
166
+ function directCommit(node: AuthoredCommit | null): AgentCommit | null {
167
+ if (!node || typeof node.oid !== 'string' || !SHA.test(node.oid) || count(node.associatedPullRequests?.totalCount) > 0) return null
168
+ const author = authorOf(node.author)
169
+ const signals = commitMarks(node.marks, { name: null, email: node.author?.email, account: commitAccount(node.author) })
170
+ if (!signals) return null
171
+ return { oid: node.oid, title: nameOf(node.messageHeadline, MAX_TITLE) ?? '', at: timeOf(node.committedDate),
172
+ author: author?.login ?? loginOf(node.author?.user), authorId: author?.id ?? null,
173
+ additions: count(node.additions), deletions: count(node.deletions), signals }
174
+ }
175
+
176
+ // A commit of the history kept an agent marked (`signals`,
177
+ // repository-history.ts historyMarks), as one of the default branch's.
178
+ const keptCommit = (commit: HistoryCommit, signals: AgentSignals): AgentCommit => ({ oid: commit.sha, title: commit.subject, at: commit.committedAt,
179
+ author: commit.authorLogin, authorId: commit.authorId ?? null, additions: commit.additions ?? 0, deletions: commit.deletions ?? 0, signals })
180
+
181
+ // Whether a pull request brought each commit, kept by the commit.
182
+ export interface CommitPulls {
183
+ read(shas: string[]): Promise<Map<string, boolean>>
184
+ write(sha: string, pulled: boolean): Promise<void>
185
+ }
186
+
187
+ // A commit GitHub has none of, as one rewritten away: its field null.
188
+ const notFound = (error: GraphqlError) => error?.type === 'NOT_FOUND'
189
+
190
+ // Those of `shas` a pull request brought: as kept (`kept`), the rest looked
191
+ // up, PULLED_COMMITS a query, and kept; one GitHub has none of, none.
192
+ async function pulledCommits(fullName: string, token: string, shas: string[], kept: CommitPulls, fetchImpl: typeof fetch) {
193
+ const [owner, name] = fullName.split('/') as [string, string]
194
+ const known = await kept.read(shas)
195
+ await inBatches(shas.filter(sha => !known.has(sha)), PULLED_COMMITS, PULLED_PARALLEL, async batch => {
196
+ const data = await githubGraphql(pulledQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map((sha, at) => [`o${at}`, sha])) }, token, fetchImpl,
197
+ notFound) as Pulled
198
+ await Promise.all(batch.map((sha, at) => {
199
+ const node = data?.repository?.[`c${at}`]
200
+ if (!node) return Promise.resolve()
201
+ known.set(sha, count(node.associatedPullRequests?.totalCount) > 0)
202
+ return kept.write(sha, known.get(sha)!)
203
+ }))
204
+ })
205
+ return new Set(shas.filter(sha => known.get(sha) === true))
206
+ }
207
+
208
+ // Where commits listed stop: those the cursor named but left unread still
209
+ // unlisted (`left`, newer than those below the commits read from the rest),
210
+ // then the parents of those listed not listed, the newest first (each by its
211
+ // child's time), as the Commits tab reads on from (repository-history.js):
212
+ // each read in its turn, as many as ALL_BRANCHES an answer, none dropped
213
+ // till MAX_FRONTIER are left (a cursor's length bounded, a URL's); null
214
+ // where none are.
215
+ function frontier(commits: HistoryCommit[], left: string[]) {
216
+ const listed = new Set(commits.map(commit => commit.sha))
217
+ const at = new Map<string, number>()
218
+ for (const commit of commits) {
219
+ for (const parent of commit.parents) if (!listed.has(parent)) at.set(parent, Math.max(at.get(parent) ?? Number.NEGATIVE_INFINITY, commit.committedAt ?? 0))
220
+ }
221
+ const below = [...at].toSorted((a, b) => b[1] - a[1]).map(([sha]) => sha)
222
+ const starts = [...new Set([...left.filter(sha => !listed.has(sha)), ...below])].slice(0, MAX_FRONTIER)
223
+ return starts.length > 0 ? starts.join(',') : null
224
+ }
225
+
226
+ // An issue's row: what it is, and who opened it.
227
+ export function issue(node: IssueNode | null): AgentIssue | null {
228
+ const at = number(node?.number)
229
+ if (!node || !at) return null
230
+ return { number: at, title: nameOf(node.title, MAX_TITLE) ?? '', state: node.state === 'OPEN' ? 'open' : 'closed', author: loginOf(node.author),
231
+ authorId: accountOf(node.author)?.id ?? null, createdAt: timeOf(node.createdAt), updatedAt: timeOf(node.updatedAt) }
232
+ }
233
+
234
+ // A branch's tip, as kept: its message's marks (messageMarks), never the
235
+ // message, and its author as read.
236
+ export interface BranchTip { marks: AgentSignals | null; author: GitActor | null }
237
+ // The tips of branches, kept by their commits, which never change.
238
+ export interface BranchTips {
239
+ read(shas: string[]): Promise<Map<string, BranchTip>>
240
+ write(sha: string, tip: BranchTip): Promise<void>
241
+ }
242
+
243
+ // A branch an agent's prefix names (`name`), its tip (`sha`) committed `at`,
244
+ // as the Agents tab shows it: its tip's author, and the marks an agent left
245
+ // on it, its tip's message's, its author's account an agent's, or its name
246
+ // under an agent's prefix, as agent-tasks.ts agentSignals tells them.
247
+ function agentBranch({ name, at }: { name: string; at: number | null }, tip: BranchTip | undefined): AgentBranch {
248
+ const author = authorOf(tip?.author)
249
+ const authored = author ? githubAgent(author.login, author.id) : null
250
+ const marks = tip?.marks
251
+ return { name, at, author: author?.login ?? loginOf(tip?.author?.user), authorId: author?.id ?? null, signals: { sessions: marks?.sessions ?? [],
252
+ tasks: marks?.tasks ?? [], claude: marks?.claude ?? false, codex: marks?.codex ?? false,
253
+ agents: APP_AGENTS.flatMap(({ id, prefix }) => marks?.agents.includes(id) || authored === id || name.startsWith(prefix) ? [id] : []) } }
254
+ }
255
+
256
+ // The branches under `prefix`, every one, as many as MAX_BRANCH_PAGES of
257
+ // them list, named in full however GitHub splits its name from the prefix.
258
+ async function prefixHeads(owner: string, name: string, prefix: string, token: string, fetchImpl: typeof fetch) {
259
+ const found: Head[] = []
260
+ let after: string | null = null
261
+ for (let page = 0; page < MAX_BRANCH_PAGES; page++) {
262
+ const refs: Connection<RefNode> | undefined = (await githubGraphql(BRANCHES_QUERY, { owner, name, prefix: `refs/heads/${prefix}`, after }, token, fetchImpl) as RefsPage)
263
+ ?.repository?.refs
264
+ for (const node of refs?.nodes ?? []) {
265
+ const ref = typeof node?.prefix === 'string' && typeof node.name === 'string' ? `${node.prefix}${node.name}` : null
266
+ const branch = ref?.startsWith('refs/heads/') ? nameOf(ref.slice('refs/heads/'.length)) : null
267
+ const sha = node?.target?.oid
268
+ if (branch?.startsWith(prefix) && typeof sha === 'string' && SHA.test(sha)) found.push({ name: branch, sha, at: timeOf(node!.target!.committedDate) })
269
+ }
270
+ const next: Connection<RefNode>['pageInfo'] = refs?.pageInfo
271
+ if (next?.hasNextPage !== true || typeof next.endCursor !== 'string') break
272
+ after = next.endCursor
273
+ }
274
+ return found
275
+ }
276
+
277
+ // The newest of the agents' branches, those each prefix names, as many as
278
+ // AGENT_BRANCHES of each, of the repository's branches as kept (`heads`, as
279
+ // the Commits tab reads them; those it stops short of, `cut`, listed by each
280
+ // prefix), their tips as kept (`tips`), those not looked up, TIPS a query,
281
+ // and kept.
282
+ async function agentBranches(fullName: string, token: string, { heads, cut }: { heads: Head[]; cut: boolean }, tips: BranchTips, fetchImpl: typeof fetch) {
283
+ const [owner, name] = fullName.split('/') as [string, string]
284
+ const all = cut ? (await Promise.all(AGENT_BRANCH_PREFIXES.map(prefix => prefixHeads(owner, name, prefix, token, fetchImpl)))).flat() : heads
285
+ const named = AGENT_BRANCH_PREFIXES.flatMap(prefix => all.filter(head => head.name.startsWith(prefix))
286
+ .toSorted((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, AGENT_BRANCHES))
287
+ const shown = named.filter((head, index) => named.findIndex(other => other.name === head.name) === index)
288
+ const shas = [...new Set(shown.map(head => head.sha))]
289
+ const known = await tips.read(shas)
290
+ await inBatches(shas.filter(sha => !known.has(sha)), TIPS, TIPS_PARALLEL, async batch => {
291
+ const data = await githubGraphql(tipsQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map((sha, at) => [`o${at}`, sha])) }, token, fetchImpl,
292
+ notFound) as Tips
293
+ await linkNoreplyUsers(batch.map((_, at) => data?.repository?.[`c${at}`]?.author), token, fetchImpl)
294
+ await Promise.all(batch.map((sha, at) => {
295
+ const node = data?.repository?.[`c${at}`]
296
+ if (!node) return Promise.resolve()
297
+ known.set(sha, { marks: messageMarks(node.message), author: node.author ?? null })
298
+ return tips.write(sha, known.get(sha)!)
299
+ }))
300
+ })
301
+ return shown.map(head => agentBranch(head, known.get(head.sha)))
302
+ }
303
+
304
+ // The commits from `head` the app agents' accounts authored (AUTHORED_QUERY),
305
+ // as kept by it (`kept`), else read and kept.
306
+ async function authoredCommits(fullName: string, token: string, head: string, kept: AuthoredCommits, fetchImpl: typeof fetch) {
307
+ const [owner, name] = fullName.split('/') as [string, string]
308
+ const key = `${head}\0${AGENT_EMAILS.join(',')}`
309
+ const known = await kept.read(key)
310
+ if (known) return known
311
+ const data = await githubGraphql(AUTHORED_QUERY, { owner, name, head, emails: AGENT_EMAILS }, token, fetchImpl) as Authored
312
+ const nodes = (data?.repository?.object?.history?.nodes ?? []).flatMap(node => node ? [authoredOf(node)] : [])
313
+ await linkNoreplyUsers(nodes.map(node => node.author), token, fetchImpl)
314
+ await kept.write(key, nodes)
315
+ return nodes
316
+ }
317
+
318
+ // What its answers are kept by, with their cursors (http.ts
319
+ // handleRepositoryAgents): bumped as what a page reads, or how it tells an
320
+ // agent, changes (with AGENT_MARKS_READ, app-agents.ts, where commits' marks
321
+ // do), so that no answer an older server kept is sent for a day.
322
+ export const AGENTS_READ_VERSION = 9
323
+
324
+ // A cursor of the default branch's commits: those the pages before stopped
325
+ // at (frontier), as many as MAX_FRONTIER, commas between, the first
326
+ // ALL_BRANCHES of them read next; one an older server gave, GitHub's,
327
+ // `<oid> <offset>`, read on from its commit.
328
+ const MAX_FRONTIER = 4 * ALL_BRANCHES
329
+ export const COMMITS_CURSOR = new RegExp(`^(?:[\\da-f]{40}(?:,[\\da-f]{40}){0,${MAX_FRONTIER - 1}}|[\\da-f]{40} \\d{1,9})$`, 'u')
330
+ const startsOf = (cursor: string) => cursor.split(/[ ,]/u).filter(part => SHA.test(part))
331
+
332
+ // A cursor of the pull requests: the last listed, by when it was updated and
333
+ // its number (`<ms>.<number>`), those below it read next; of a search's page
334
+ // all updated together, the time it searched by and where its page ended too
335
+ // (`~<ms>~<cursor>`), those updated with them read on through the same
336
+ // search; any other, as an older server gave (GitHub's), read from the
337
+ // newest again.
338
+ const POSITION = /^(\d{1,15})\.(\d{1,10})(?:~(\d{1,15})~([\w+/=-]{1,200}))?$/u
339
+ const positionOf = (item: AgentPull) => `${item.updatedAt ?? 0}.${item.number}`
340
+ // Those updated by a time, as GitHub's search takes it, to the second; any
341
+ // where it names none.
342
+ const updatedBy = (at: number) => Number.isFinite(at) ? ` updated:<=${new Date(at).toISOString().replace(/\.\d{3}Z$/u, '+00:00')}` : ''
343
+
344
+ // A branch: its name, its tip, and when that was committed.
345
+ type Head = { name: string; sha: string; at: number | null }
346
+ // The commits the app agents' accounts authored from a head (AUTHORED_QUERY),
347
+ // kept by it and the addresses they were asked by (`key`).
348
+ export interface AuthoredCommits {
349
+ read(key: string): Promise<AuthoredCommit[] | null>
350
+ write(key: string, commits: AuthoredCommit[]): Promise<void>
351
+ }
352
+ // What the repository's tabs read and keep together (http.ts): its pull
353
+ // requests and its issues (repository-pulls.ts PullList, IssueList), each
354
+ // read as far back as their first page where none are, and those updated
355
+ // since read as they are (keptList), its default branch's head (defaultTip),
356
+ // its branches, as the Commits tab reads them (`heads`, whether they stop
357
+ // short of some, `cut`), their tips (`tips`), and the commits its app
358
+ // agents' accounts authored (`authored`).
359
+ export interface Kept { pulls(): Promise<PullList>; issues(): Promise<IssueList>; head(): Promise<string | null>; heads(): Promise<{ heads: Head[]; cut: boolean }>
360
+ tips: BranchTips
361
+ authored: AuthoredCommits }
362
+
363
+ // A page of the repository's pull requests, the most recently updated
364
+ // first, below the one `cursor` names (none: from the newest): of those kept
365
+ // (`kept`), as far back as every one is read; past them, as GitHub's search
366
+ // finds those updated before, a page a query. With where it reads on from,
367
+ // null where none are left: past every one kept where all are.
368
+ async function agentPulls(fullName: string, token: string, cursor: string | null, kept: Kept['pulls'], fetchImpl: typeof fetch) {
369
+ const at = cursor === null ? null : POSITION.exec(cursor)
370
+ const [time, past] = at ? [Number(at[1]), Number(at[2])] : [Number.POSITIVE_INFINITY, 0]
371
+ const below = (item: AgentPull) => (item.updatedAt ?? 0) < time || ((item.updatedAt ?? 0) === time && item.number < past)
372
+ const list = await kept()
373
+ // Since when every one updated is read (as repository-pulls.ts pullsReadTo
374
+ // tells, which reads its pages by these fields): all since the year's
375
+ // start, -∞.
376
+ const read = list.next ? list.next.to ?? Number.POSITIVE_INFINITY : list.reach ?? Number.NEGATIVE_INFINITY
377
+ // As far back as every one is read: the year's start, where all since are.
378
+ const bottom = Math.max(read, list.since)
379
+ const listed = list.rows.filter(item => below(item) && (item.updatedAt ?? 0) >= bottom)
380
+ const whole = list.all === true && read === Number.NEGATIVE_INFINITY
381
+ if (listed.length > 0) {
382
+ const page = listed.slice(0, AGENT_PULLS_PAGE)
383
+ return { pulls: page, after: listed.length > page.length || !whole ? positionOf(page.at(-1)!) : null }
384
+ }
385
+ if (whole) return { pulls: [], after: null }
386
+ // Those updated by when the last was, those updated with it again among
387
+ // them (GitHub orders those by no number), the last itself left out; a
388
+ // page all updated together read on through the same search, past them.
389
+ const [by, through] = at?.[3] === undefined ? [Math.min(time, bottom), null] : [Number(at[3]), at[4]!]
390
+ const found = await githubGraphql(SEARCH_QUERY, { query: `repo:${fullName} is:pr sort:updated-desc${updatedBy(by)}`, after: through }, token, fetchImpl) as Found
391
+ const nodes = found?.search?.nodes ?? []
392
+ const rows = nodes.flatMap(node => pull(node) ?? [])
393
+ const last = rows.at(-1)
394
+ const ended = found?.search?.pageInfo?.endCursor
395
+ const together = Number.isFinite(by) && rows.every(item => item.updatedAt === last?.updatedAt) && typeof ended === 'string' && /^[\w+/=-]{1,200}$/u.test(ended)
396
+ const after = nodes.length >= AGENT_PULLS_PAGE && last ? `${positionOf(last)}${together ? `~${by}~${ended}` : ''}` : null
397
+ return { pulls: rows.filter(item => item.updatedAt !== time || item.number !== past), after: after === cursor ? null : after }
398
+ }
399
+
400
+ // The repository's latest issues, as many as AGENT_ISSUES: of those kept
401
+ // (`kept`), where they hold as many, or every one the repository has; else
402
+ // read, as those kept hold none updated before the year they are read for.
403
+ async function latestIssues(fullName: string, token: string, kept: Kept['issues'], fetchImpl: typeof fetch): Promise<AgentIssue[]> {
404
+ const list = await kept()
405
+ if (list.rows.length >= AGENT_ISSUES || list.all) return list.rows.slice(0, AGENT_ISSUES).map(({ bot: _bot, closedAt: _closedAt, reason: _reason, ...row }) => row)
406
+ const [owner, name] = fullName.split('/') as [string, string]
407
+ const data = await githubGraphql(ISSUES_QUERY, { owner, name }, token, fetchImpl) as Issues
408
+ return (data?.repository?.issues?.nodes ?? []).flatMap(node => issue(node) ?? [])
409
+ }
410
+
411
+ // A page of what the repository's agents left: its pull requests below the
412
+ // one the cursor `pulls` names, as kept (`kept`, agentPulls), its default
413
+ // branch's commits on from those `commits` names, as its history is kept
414
+ // (`pages`, historyFrom: the first page from its head, as kept), whether a
415
+ // pull request brought those marked kept (`pulled`) (the first page where
416
+ // neither is named, with its latest issues, as kept, its agents' branches
417
+ // and the commits the app agents' accounts authored, and the app agents it
418
+ // may name), and the cursors reading on from each, null where either is
419
+ // read whole.
420
+ export async function repositoryAgents(fullName: string, token: string, cursors: { pulls: string | null; commits: string | null }, pages: HistoryPages,
421
+ pulled: CommitPulls, kept: Kept, fetchImpl: typeof fetch = globalThis.fetch) {
422
+ const first = cursors.pulls === null && cursors.commits === null
423
+ const tip = first ? kept.head() : Promise.resolve(null)
424
+ const [head, issues, branches, nodes, pulls] = await Promise.all([
425
+ tip,
426
+ first ? latestIssues(fullName, token, kept.issues, fetchImpl) : [],
427
+ first ? kept.heads().then(heads => agentBranches(fullName, token, heads, kept.tips, fetchImpl)) : [],
428
+ tip.then(sha => typeof sha === 'string' && SHA.test(sha) ? authoredCommits(fullName, token, sha, kept.authored, fetchImpl) : []),
429
+ first || cursors.pulls !== null ? agentPulls(fullName, token, cursors.pulls, kept.pulls, fetchImpl) : { pulls: [], after: null },
430
+ ])
431
+ const named = cursors.commits === null ? first && typeof head === 'string' && SHA.test(head) ? [head] : [] : startsOf(cursors.commits)
432
+ const starts = named.slice(0, ALL_BRANCHES)
433
+ const history = starts.length > 0 ? (await historyFrom(fullName, token, starts, pages, fetchImpl)).commits : []
434
+ const marked = history.flatMap(commit => { const signals = historyMarks(commit); return signals ? [{ commit, signals }] : [] })
435
+ const brought = await pulledCommits(fullName, token, marked.map(({ commit }) => commit.sha), pulled, fetchImpl)
436
+ const commits = [...marked.flatMap(({ commit, signals }) => brought.has(commit.sha) ? [] : [keptCommit(commit, signals)]), ...nodes.flatMap(node => directCommit(node) ?? [])]
437
+ const after = { pulls: pulls.after, commits: frontier(history, named.slice(ALL_BRANCHES)) }
438
+ return {
439
+ pulls: pulls.pulls.map(({ bot: _bot, merged: _merged, ...row }: AgentPull & { bot?: unknown; merged?: unknown }) => row),
440
+ commits: commits.filter((commit, index) => commits.findIndex(other => other.oid === commit.oid) === index),
441
+ ...first ? {
442
+ issues,
443
+ branches,
444
+ agents: APP_AGENTS.map(({ id, name: agent, avatar }): AppAgent => ({ id, name: agent, ...avatar ? { avatar } : {} })),
445
+ } : {},
446
+ more: after.pulls !== null || after.commits !== null, after,
447
+ }
448
+ }
@@ -0,0 +1,91 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { reportRepoGithub } from '@preventive/report'
3
+ import { type RepositoryAliasInput, matchRepositoryAlias, repositoryAliasNeedsFilePrefix } from '../common/managed/repository-alias.ts'
4
+ import type { ManagedDb } from './db.ts'
5
+ import { ManagedMutationError } from './management.ts'
6
+ import { normalizeTeamPath } from './repo-path.ts'
7
+ import type { ManagedSql } from './sql.ts'
8
+
9
+ export const REPOSITORY_ALIAS_SCHEMA = `
10
+ CREATE TABLE IF NOT EXISTS managed_repository_alias (
11
+ id TEXT PRIMARY KEY,
12
+ old_repo TEXT NOT NULL,
13
+ old_path TEXT NOT NULL DEFAULT '',
14
+ repo_id INTEGER NOT NULL REFERENCES managed_selected_repo(repo_id) ON DELETE CASCADE,
15
+ new_path TEXT NOT NULL DEFAULT '',
16
+ UNIQUE(old_repo, old_path)
17
+ ) STRICT;
18
+ `
19
+ export interface RepositoryAlias extends RepositoryAliasInput { id: string }
20
+ export interface RepositoryAliasStore {
21
+ listRepositoryAliases(): Promise<RepositoryAlias[]>
22
+ saveRepositoryAlias(sessionId: string, id: string | null, input: unknown): Promise<RepositoryAlias>
23
+ deleteRepositoryAlias(sessionId: string, id: string): Promise<void>
24
+ getRepositoryImportLocation(github: string, directory: string, filePrefix?: string): Promise<{ repoId: number | null; directory: string }>
25
+ }
26
+
27
+ // Keep expensive inventory reads outside database transactions and skip them
28
+ // unless a currently connected alias can use the bundle's existing paths.
29
+ export async function resolveRepositoryImportLocation(db: ManagedDb, github: string, directory: string, loadFilePrefix: () => Promise<string>) {
30
+ const aliases = await db.listRepositoryAliases()
31
+ let filePrefix = ''
32
+ if (repositoryAliasNeedsFilePrefix(github, directory, aliases)) {
33
+ const active = new Set((await db.listSelectedRepos()).map(repo => repo.repoId))
34
+ if (repositoryAliasNeedsFilePrefix(github, directory, aliases.filter(alias => active.has(alias.repoId)))) filePrefix = await loadFilePrefix()
35
+ }
36
+ return db.getRepositoryImportLocation(github, directory, filePrefix)
37
+ }
38
+
39
+ function parseAlias(input: unknown): RepositoryAliasInput {
40
+ if (!input || typeof input !== 'object') throw new ManagedMutationError(400, 'bad-alias')
41
+ const { oldRepo, oldPath, repoId, newPath } = input as RepositoryAliasInput
42
+ const github = typeof oldRepo === 'string' ? reportRepoGithub({ repo: { github: oldRepo.trim() } }) : null
43
+ if (!github) throw new ManagedMutationError(400, 'bad-old-repo')
44
+ if (typeof oldPath !== 'string' || typeof newPath !== 'string') throw new ManagedMutationError(400, 'bad-directory')
45
+ const old = normalizeTeamPath(oldPath), target = normalizeTeamPath(newPath)
46
+ if (!old.ok || !target.ok) throw new ManagedMutationError(400, 'bad-directory')
47
+ if (!Number.isSafeInteger(repoId) || repoId <= 0) throw new ManagedMutationError(400, 'bad-repo')
48
+ return { oldRepo: github.toLowerCase(), oldPath: old.path ?? '', repoId, newPath: target.path ?? '' }
49
+ }
50
+
51
+ export function repositoryAliasMethods(db: ManagedSql): RepositoryAliasStore {
52
+ async function authorize(sessionId: string) {
53
+ const user = await db.prepare(`SELECT u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
54
+ WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, Date.now()) as { role: string } | undefined
55
+ if (!user) throw new ManagedMutationError(401, 'unauthenticated')
56
+ if (user.role !== 'admin') throw new ManagedMutationError(403, 'forbidden')
57
+ }
58
+ async function listRepositoryAliases() {
59
+ return await db.prepare(`SELECT id, old_repo AS oldRepo, old_path AS oldPath, repo_id AS repoId, new_path AS newPath
60
+ FROM managed_repository_alias ORDER BY old_repo, old_path, id`).all() as RepositoryAlias[]
61
+ }
62
+ return {
63
+ listRepositoryAliases,
64
+ async saveRepositoryAlias(sessionId, id, input) {
65
+ await authorize(sessionId)
66
+ const alias = parseAlias(input)
67
+ if (!await db.prepare('SELECT repo_id FROM managed_selected_repo WHERE repo_id = ? AND active = 1').get(alias.repoId)) throw new ManagedMutationError(400, 'bad-repo')
68
+ if (id && !await db.prepare('SELECT id FROM managed_repository_alias WHERE id = ?').get(id)) throw new ManagedMutationError(404, 'no-alias')
69
+ const duplicate = await db.prepare('SELECT id FROM managed_repository_alias WHERE old_repo = ? AND old_path = ?').get(alias.oldRepo, alias.oldPath) as { id: string } | undefined
70
+ if (duplicate && duplicate.id !== id) throw new ManagedMutationError(409, 'alias-exists')
71
+ const key = id ?? randomUUID()
72
+ await db.prepare(`INSERT INTO managed_repository_alias (id, old_repo, old_path, repo_id, new_path) VALUES (?, ?, ?, ?, ?)
73
+ ON CONFLICT(id) DO UPDATE SET old_repo = excluded.old_repo, old_path = excluded.old_path, repo_id = excluded.repo_id, new_path = excluded.new_path`)
74
+ .run(key, alias.oldRepo, alias.oldPath, alias.repoId, alias.newPath)
75
+ return { id: key, ...alias }
76
+ },
77
+ async deleteRepositoryAlias(sessionId, id) {
78
+ await authorize(sessionId)
79
+ if (!(await db.prepare('DELETE FROM managed_repository_alias WHERE id = ?').run(id)).changes) throw new ManagedMutationError(404, 'no-alias')
80
+ },
81
+ async getRepositoryImportLocation(github, directory, filePrefix = '') {
82
+ const alias = matchRepositoryAlias(github, directory, await listRepositoryAliases(), filePrefix)
83
+ const normalized = normalizeTeamPath(alias?.directory ?? directory)
84
+ if (!normalized.ok) throw new ManagedMutationError(400, 'bad-directory')
85
+ const repo = await (alias
86
+ ? db.prepare('SELECT repo_id AS repoId FROM managed_selected_repo WHERE active = 1 AND repo_id = ?').get(alias.repoId)
87
+ : db.prepare('SELECT repo_id AS repoId FROM managed_selected_repo WHERE active = 1 AND LOWER(full_name) = ?').get(github.toLowerCase())) as { repoId: number } | undefined
88
+ return { repoId: repo?.repoId ?? null, directory: normalized.path ?? '' }
89
+ },
90
+ }
91
+ }