@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,27 @@
1
+ import type { StoredUser } from './db.ts'
2
+
3
+ const WHITEHAT = [
4
+ 291301, // ChALkeR
5
+ 259122746, // exo-nikita
6
+ 247161625, // ex0sec
7
+ ]
8
+ const whitehatIds = new Set(WHITEHAT)
9
+
10
+ // (1) Server permission to add repositories. Keep this independent of GitHub
11
+ // identity and ecosystem exceptions if other install modes broaden it later.
12
+ export function canAddRepositories(user: StoredUser): boolean {
13
+ return user.role === 'admin'
14
+ }
15
+
16
+ // (3b) Only the ecosystem safeguard for public repositories is bypassed by
17
+ // WHITEHAT. Callers must separately establish (0) server readability, (1) add
18
+ // permission, and (2) server admin or GitHub access to this repository.
19
+ export function passesPublicRepositorySafeguard(githubUserId: number | null, involved: boolean): boolean {
20
+ return involved || (githubUserId != null && whitehatIds.has(githubUserId))
21
+ }
22
+
23
+ // Arbitrary public repositories have no involvement evidence, so (2) requires
24
+ // admin even if (1) expands in the future. This capability is also the UI gate.
25
+ export function canAddAnyPublicRepository(user: StoredUser, githubUserId: number | null): boolean {
26
+ return canAddRepositories(user) && user.role === 'admin' && passesPublicRepositorySafeguard(githubUserId, false)
27
+ }
@@ -0,0 +1,156 @@
1
+ // A repository's pull requests, the most recently updated first, as kept
2
+ // once for both of those that read them so (http.ts keptPulls): the Pulse,
3
+ // telling whose its year's commits are by those merged (agent-merges.ts
4
+ // agentMerges), and the Agents tab, its pages of them (repository-agents.ts
5
+ // agentPulls). Those updated since its year's start, a few queries an
6
+ // answer, the rest read by the next, and those updated since read as they
7
+ // are. Each kept as the Agents tab shows it (repository-agents.ts pull: the
8
+ // marks an agent left in its texts, never the texts) and, one merged, what
9
+ // it brought the default branch, as the Pulse tells it: an agent that
10
+ // commits as whoever ran it (Codex's own) leaves its commits no mark, its
11
+ // pull request's branch alone naming it (app-agents.ts
12
+ // AGENT_BRANCH_PREFIXES), or a label one who ran it gave it naming the agent
13
+ // (agent-tasks.ts labelAgents), as the Agents tab tells an agent's work; or
14
+ // its author, an account agents work as (github-agents.ts AGENT_OPERATED),
15
+ // or any GitHub App's bot.
16
+ import { type AgentPull, labelAgents } from '../common/managed/agent-tasks.ts'
17
+ import { GITHUB_AGENTS, agentOperated } from '../common/managed/github-agents.ts'
18
+ import { AGENT_BRANCH_PREFIXES } from './app-agents.ts'
19
+ import { AGENT_PULL_FIELDS, type PullNode, pull } from './repository-agents.ts'
20
+ import { githubGraphql } from './repository-history.ts'
21
+
22
+ // What the pull requests kept are read by, its version: bumped as what a
23
+ // page reads, or how one is told, changes, its pull requests read again
24
+ // (http.ts keptPulls keys them by AGENTS_READ_VERSION and the Pulse's
25
+ // PULSE_YEAR_VERSION too, as either changes how it tells an agent's).
26
+ export const PULLS_READ_VERSION = 1
27
+ // The pull requests read an answer, a page a query, as many queries as
28
+ // PULLS_QUERIES, the rest left for the next to ask; of each merged, as many
29
+ // of its latest commits as MERGED_COMMITS.
30
+ const PULLS_PAGE = 100
31
+ const PULLS_QUERIES = 5
32
+ const MERGED_COMMITS = 100
33
+ const LABEL_AGENTS = Object.keys(GITHUB_AGENTS)
34
+ const SHA = /^[a-f\d]{40}$/u
35
+
36
+ const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
37
+ const timeOf = (value: unknown) => {
38
+ const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
39
+ return Number.isSafeInteger(at) ? at : null
40
+ }
41
+
42
+ // A pull request as read: its row on the Agents tab, and one merged, what
43
+ // it brought the default branch (`merged`).
44
+ export type KeptPull = AgentPull & { merged?: Merged }
45
+ // What a merged pull request brought: its merge commit (`sha`), when it was
46
+ // merged (`at`) and whether it was an agent's (agentPull); an agent's merged
47
+ // with a merge commit, which a year counts none of, its own commits too
48
+ // (`own`, its latest MERGED_COMMITS); and of one of more commits whose merge
49
+ // commit has one parent, its commits' subjects, the oldest first, for those
50
+ // it was rebased as to be told by (agent-merges.ts agentCommits), those of
51
+ // its empty commits left out, as a rebase leaves them, and how many more it
52
+ // had than those read (`more`).
53
+ export interface Merged { sha: string; at: number; agent: boolean; own?: string[]; subjects?: string[]; more?: number }
54
+ // The pull requests updated since a year's start (`since`) as read so far
55
+ // (readPulls), the most recently updated first; when the newest read was
56
+ // last updated, those updated since read again (`through`, null till all
57
+ // are read once), and where those read reach back no further than was
58
+ // needed, when the oldest of them was updated (`reach`); whether every one
59
+ // the repository has is read (`all`); and a read not finished: where it goes
60
+ // on (`after`), when the newest it began with was updated (`from`), the
61
+ // oldest it reads to (`stop`), how many it has read (`read`) and when the
62
+ // oldest of them was updated (`to`).
63
+ export interface PullList { pulls: KeptPull[]; since: number; through: number | null; reach?: number; all?: true
64
+ next: { after: string; from: number; stop: number; read: number; to?: number } | null }
65
+
66
+ // Since when every pull request updated is read: all of them since the
67
+ // year's start, -∞; none yet, ∞.
68
+ export const pullsReadTo = (list: Pick<PullList, 'next' | 'reach'>) => list.next ? list.next.to ?? Number.POSITIVE_INFINITY : list.reach ?? Number.NEGATIVE_INFINITY
69
+
70
+ const PULLS_QUERY = `query($owner: String!, $name: String!, $after: String) {
71
+ repository(owner: $owner, name: $name) {
72
+ pullRequests(first: ${PULLS_PAGE}, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
73
+ pageInfo { hasNextPage endCursor }
74
+ nodes { ${AGENT_PULL_FIELDS} mergedAt mergeCommit { oid parents { totalCount } }
75
+ commits(last: ${MERGED_COMMITS}) { totalCount nodes { commit { oid messageHeadline changedFilesIfAvailable } } } }
76
+ }
77
+ }
78
+ }`
79
+ type MergedNode = PullNode & { mergedAt?: unknown; mergeCommit?: { oid?: unknown; message?: unknown; parents?: { totalCount?: unknown } | null } | null
80
+ author?: PullNode['author'] & { __typename?: unknown }
81
+ commits?: { totalCount?: unknown; nodes?: ({ commit?: { oid?: unknown; messageHeadline?: unknown; changedFilesIfAvailable?: unknown } | null } | null)[] } | null }
82
+ type PullsPage = { repository?: { pullRequests?: { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: (MergedNode | null)[] } | null } | null }
83
+
84
+ const agentBranch = (name: unknown) => typeof name === 'string' && AGENT_BRANCH_PREFIXES.some(prefix => name.startsWith(prefix))
85
+ // Whether a pull request is an agent's: by its branch, a label naming one,
86
+ // or its author, an account agents work as or a bot (GitHub's GraphQL API
87
+ // naming a bot by its app's login alone, its type telling it).
88
+ const agentPull = ({ headRefName, labels, author }: MergedNode) => agentBranch(headRefName)
89
+ || labelAgents((labels?.nodes ?? []).map(label => label?.name), LABEL_AGENTS).length > 0 || author?.__typename === 'Bot'
90
+ || agentOperated(typeof author?.login === 'string' ? author.login : null, Number.isSafeInteger(author?.databaseId) ? author!.databaseId as number : null)
91
+
92
+ // What a pull request merged brought (Merged); null for one not merged.
93
+ function mergedOf(node: MergedNode): Merged | null {
94
+ const [at, sha] = [timeOf(node.mergedAt), node.mergeCommit?.oid]
95
+ if (at === null || typeof sha !== 'string' || !SHA.test(sha)) return null
96
+ const commits = (node.commits?.nodes ?? []).map(item => item?.commit)
97
+ const merging = count(node.mergeCommit?.parents?.totalCount) > 1
98
+ const copied = commits.filter(commit => commit?.changedFilesIfAvailable !== 0)
99
+ // Of one of more commits than its latest read, how many more it had.
100
+ const more = Math.max(0, count(node.commits?.totalCount) - commits.length)
101
+ const subjects = !merging && (copied.length > 1 || more > 0) && copied.length > 0 && copied.every(commit => typeof commit?.messageHeadline === 'string')
102
+ ? copied.map(commit => commit!.messageHeadline as string) : null
103
+ const agent = agentPull(node)
104
+ const own = agent && merging ? commits.flatMap(commit => typeof commit?.oid === 'string' && SHA.test(commit.oid) ? [commit.oid] : []) : []
105
+ return { sha, at, agent, ...own.length > 0 ? { own } : {}, ...subjects ? { subjects, ...more > 0 ? { more } : {} } : {} }
106
+ }
107
+
108
+ // The most recently updated first, the higher number first of those
109
+ // updated together.
110
+ const newestFirst = (a: AgentPull, b: AgentPull) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || b.number - a.number
111
+
112
+ // The pull requests updated since `since`, read the most recently updated
113
+ // first (a pull request is updated when merged, or after), on from what was
114
+ // `kept`: till one updated before the year, or before the newest read before
115
+ // was, as many queries an answer as PULLS_QUERIES, a read not finished going
116
+ // on from where it was left by the next, or once those updated since `need`
117
+ // are all read; where those read reached no further back than they needed,
118
+ // and `need` is further, read again from the newest on, as far back as it
119
+ // needs. Each kept as last read, one updated before the year forgotten.
120
+ export async function readPulls(fullName: string, token: string, since: number, kept: PullList | null,
121
+ fetchImpl: typeof fetch = globalThis.fetch, need = since): Promise<PullList> {
122
+ const [owner, name] = fullName.split('/') as [string, string]
123
+ const pulls = new Map((kept?.pulls ?? []).filter(item => (item.updatedAt ?? 0) >= since).map(item => [item.number, item]))
124
+ // Every one read, as long as none was forgotten, the year begun since.
125
+ const whole = kept?.all === true && pulls.size === kept.pulls.length
126
+ const further = !kept?.next && kept?.reach !== undefined && kept.reach > need
127
+ const stop = kept?.next?.stop ?? (further ? since : Math.max(since, kept?.through ?? since))
128
+ const result = (through: number | null, next: PullList['next'], reach: number | undefined, all = whole): PullList => ({
129
+ pulls: [...pulls.values()].toSorted(newestFirst), since, through, ...reach === undefined ? {} : { reach }, ...all ? { all: true as const } : {}, next })
130
+ let { after = null, from = null, read = 0, to = null }: { after?: string | null; from?: number | null; read?: number; to?: number | null } = kept?.next ?? {}
131
+ for (let queries = 0; queries < PULLS_QUERIES; queries++) {
132
+ const page = (await githubGraphql(PULLS_QUERY, { owner, name, after }, token, fetchImpl) as PullsPage | null)?.repository?.pullRequests
133
+ const end = page?.pageInfo?.hasNextPage !== true || typeof page.pageInfo.endCursor !== 'string'
134
+ let crossed = false
135
+ for (const node of page?.nodes ?? []) {
136
+ const updated = timeOf(node?.updatedAt)
137
+ if (!node || updated === null) continue
138
+ if (updated < stop) { crossed = true; break }
139
+ from ??= updated
140
+ to = updated
141
+ read++
142
+ const row = pull(node)
143
+ const merged = mergedOf(node)
144
+ if (row) pulls.set(row.number, merged ? { ...row, merged } : row)
145
+ }
146
+ // Read as far back as it reads to: the year's start, every one since
147
+ // read; else as far as those read before reached. Read to the last
148
+ // the repository has, every one of them.
149
+ if (crossed || end) return result(from ?? stop, null, stop > since ? kept?.reach : undefined, (end && !crossed) || (whole && stop > since))
150
+ after = page!.pageInfo!.endCursor as string
151
+ // Read as far back as `need`: finished, so far back, those updated
152
+ // since read by the next as ever.
153
+ if (need > since && to !== null && to < need) return result(Math.max(from ?? stop, kept?.through ?? Number.NEGATIVE_INFINITY), null, to)
154
+ }
155
+ return result(kept?.through ?? null, { after: after!, from: from ?? stop, stop, read, ...to === null ? {} : { to } }, kept?.reach)
156
+ }
@@ -0,0 +1,460 @@
1
+ // What the code viewer's Pulse tab shows of a repository
2
+ // (GET /api/admin/repositories/pulse): how its default branch moves, week by
3
+ // week over the last year: its commits each day, the lines they add and
4
+ // remove, those an agent made or co-authored, who made them and when in the
5
+ // week, counted from its commits as the Commits tab reads and keeps them
6
+ // (repository-history.ts historyFrom), so that only those not kept yet are
7
+ // read of GitHub, a few queries an answer, the client asking again for the
8
+ // rest; a year past YEAR_COMMITS is counted by GitHub's statistics instead,
9
+ // which GitHub computes when first asked and again once the default branch
10
+ // moves, answering 202 till they are (told pending, StatsPending, for the
11
+ // client to ask again). And, since
12
+ // the start of the week `weeks` weeks back, and over the same days of the
13
+ // week `weeks` weeks earlier, the pull requests merged and opened and the
14
+ // issues closed and opened, how many and the latest of each.
15
+ import { agentOperated, githubAgent } from '../common/managed/github-agents.ts'
16
+ import { GithubApiError, githubJson } from './github-app.ts'
17
+ import { shareGithubRead } from './github-requests.ts'
18
+ import { coauthorsOf } from './commit-coauthors.ts'
19
+ import { ACTOR, type GraphAccount, type HistoryCommit, type HistoryPages, accountOf, actorOf, githubGraphql, historyFrom } from './repository-history.ts'
20
+
21
+ export const PULSE_WEEKS = [1, 4, 13, 52]
22
+ // What a year counted from its commits holds, its version: kept years of
23
+ // another are counted again.
24
+ export const PULSE_YEAR_VERSION = 12
25
+ const WEEK_MS = 7 * 24 * 60 * 60_000
26
+ const DAY_MS = 24 * 60 * 60_000
27
+ const MAX_CONTRIBUTORS = 100
28
+ const DIRECT_LISTED = 500
29
+ const LISTED = 10
30
+ // Merged pull requests and closed issues are searched by when each was last
31
+ // updated, GitHub sorting by no merge or close: as many as this are read, and
32
+ // the latest merged or closed of them listed.
33
+ const SEARCHED = 50
34
+ // A year of more commits than this is counted by GitHub's statistics: read a
35
+ // page at a time, it would take a hundred queries. Of those not kept, as
36
+ // many queries are made an answer as YEAR_QUERIES, each reading on from as
37
+ // many commits as YEAR_STARTS.
38
+ export const YEAR_COMMITS = 10_000
39
+ const YEAR_QUERIES = 5
40
+ const YEAR_STARTS = 10
41
+ // Commits are read on from as far as this before the year, a commit's clock
42
+ // not always later than its parents': one dated before the year may have a
43
+ // parent dated in it.
44
+ const YEAR_SKEW_MS = 30 * DAY_MS
45
+ const MAX_TITLE = 1000
46
+ const SHA = /^[a-f\d]{40}$/u
47
+
48
+ // GitHub still computing a statistic.
49
+ export class StatsPending extends Error {
50
+ constructor() { super('github-stats-pending') }
51
+ }
52
+
53
+ // A week: its commits each day (Sunday first) and in all, the lines they
54
+ // added and removed, and, counted from its commits, each day's lines
55
+ // (`dayLines`, `[added, removed]`), the commits that release the repository
56
+ // each day (`releaseDays`, release-commits.ts) and, of the rest, those an
57
+ // agent made or co-authored (`agentDays`), and those pushed to the branch
58
+ // directly (`directDays`, agent-merges.ts directCommits), which GitHub's
59
+ // statistics don't count.
60
+ export interface PulseWeek { week: number; days: number[]; commits: number; additions: number | null; deletions: number | null; dayLines?: [number, number][]
61
+ agentDays?: number[]; releaseDays?: number[]; directDays?: number[] }
62
+ // A contributor, by their login and their account's numeric id (`id`, where
63
+ // GitHub named it), else by the name their commits carry, and their weeks,
64
+ // each `[week, commits, additions, deletions]`, those with any; counted from
65
+ // their commits, their days too, each `[day, commits, additions, deletions,
66
+ // agents, releases, direct]`, `releases` those that release the repository,
67
+ // `agents`, of the rest, those an agent took part in (withAgent), and
68
+ // `direct` those pushed to the branch directly.
69
+ export interface PulseContributor { login: string | null; id?: number | null; name: string | null; weeks: [number, number, number, number][]
70
+ days?: [number, number, number, number, number, number, number][] }
71
+ // A commit pushed to the branch directly, as Pulse lists it: its id, when it
72
+ // was committed, its subject, its author, by account, else name, and what it
73
+ // is: a release, else one an agent took part in, else one without.
74
+ export interface PulseCommit { sha: string; at: number; subject: string; login: string | null; id: number | null; name: string | null
75
+ kind: 'agent' | 'without' | 'release' }
76
+ // Commits by day of the week and hour (`punch[day][hour]`, Sunday first), in
77
+ // each commit's own time zone, and, counted from its commits, each period's
78
+ // (`punches`, by its weeks); `lines` false where GitHub counts none, null
79
+ // while it is counting them; counted from its commits, the latest pushed to
80
+ // the branch directly (`directCommits`, as many as DIRECT_LISTED); and,
81
+ // counted from its commits read no further back than a period needed, when
82
+ // from it is counted whole (`from`), absent for the year whole.
83
+ export interface PulseStats { weeks: PulseWeek[]; contributors: PulseContributor[]; punch: number[][]; punches?: Record<number, number[][]>; lines: boolean | null
84
+ directCommits?: PulseCommit[]; from?: number }
85
+ // The lines added and removed a week, and each contributor's, as GitHub's
86
+ // statistics count them, `lines` false where it counts none.
87
+ export interface PulseLines { weeks: [number, number, number][]; contributors: { login: string; weeks: [number, number, number][] }[]; lines: boolean }
88
+ // The days (`from`, `to`, both days, UTC; `to` none, on) and the authors (GitHub
89
+ // logins, any of them) a period's pull requests and issues are narrowed to.
90
+ export interface PulseNarrowing { from: number; to: number | null; authors: string[] }
91
+ // The year's commits read so far: all of them, or as far back as asked
92
+ // (`commits`, `from`), else how many (`read`), and whether past
93
+ // YEAR_COMMITS (`tooMany`).
94
+ export type YearCommits = { complete: true; commits: HistoryCommit[]; passed: HistoryCommit[]; from: number | null } | { complete: false; read: number; tooMany: boolean }
95
+
96
+ const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
97
+ const timeOf = (value: unknown) => {
98
+ const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
99
+ return Number.isSafeInteger(at) ? at : null
100
+ }
101
+ const repoApi = (fullName: string) => `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
102
+ const emptyPunch = () => Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0))
103
+
104
+ // One statistic: its rows once GitHub has computed it, 'pending' while it
105
+ // computes them (202), none for a repository with no commits (204, no body),
106
+ // 'too-large' where it counts none (lines past 10,000 commits, 422).
107
+ function statistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch) {
108
+ // Stats and lines both need contributors. Share by the original transport;
109
+ // the per-call status wrapper below would otherwise give each its own read.
110
+ return shareGithubRead(fetchImpl, JSON.stringify(['statistic', fullName, token, kind]), () => readStatistic(fullName, token, kind, fetchImpl))
111
+ }
112
+
113
+ async function readStatistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch): Promise<unknown[] | 'pending' | 'too-large'> {
114
+ let status = 0
115
+ const answered: typeof fetch = async (input, init) => {
116
+ const res = await fetchImpl(input, init)
117
+ status = res.status
118
+ return res
119
+ }
120
+ try {
121
+ const data = await githubJson(`${repoApi(fullName)}/stats/${kind}`, token, answered)
122
+ if (status === 202) return 'pending'
123
+ if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
124
+ return data
125
+ } catch (err) {
126
+ if (status === 202) return 'pending'
127
+ if (status === 204) return []
128
+ if (err instanceof GithubApiError && err.message === 'github-status-422') return 'too-large'
129
+ throw err
130
+ }
131
+ }
132
+
133
+ // `kinds` of GitHub's statistics, each asked once, together; any still
134
+ // computed, StatsPending.
135
+ async function statistics(fullName: string, token: string | null, kinds: string[], fetchImpl: typeof fetch) {
136
+ const read = await Promise.all(kinds.map(kind => statistic(fullName, token, kind, fetchImpl)))
137
+ if (read.includes('pending')) throw new StatsPending()
138
+ return read.map(rows => rows === 'too-large' ? null : rows as unknown[])
139
+ }
140
+
141
+ type ActivityRow = { week?: unknown; days?: unknown; total?: unknown }
142
+ type ContributorRow = { author?: { login?: unknown; id?: unknown } | null; weeks?: { w?: unknown; a?: unknown; d?: unknown; c?: unknown }[] }
143
+
144
+ // The default branch's commits over the last year as GitHub's statistics
145
+ // count them, for a year past YEAR_COMMITS: a week a row, the oldest first,
146
+ // its commits each day (Sunday first) and in all; its contributors (as many
147
+ // as MAX_CONTRIBUTORS, the most commits that year first) and their weeks;
148
+ // and its commits by day of the week and hour, in each commit's own time
149
+ // zone. Lines are GitHub's to count apart (repositoryLines).
150
+ export async function repositoryStats(fullName: string, token: string | null, fetchImpl: typeof fetch = globalThis.fetch): Promise<PulseStats> {
151
+ const [activityRows = null, contributorRows = null, punchRows = null] = await statistics(fullName, token, ['commit_activity', 'contributors', 'punch_card'], fetchImpl)
152
+ const weeks = ((activityRows ?? []) as ActivityRow[]).flatMap(row => {
153
+ if (!Number.isSafeInteger(row?.week)) return []
154
+ const days = Array.from({ length: 7 }, (_, day) => count(Array.isArray(row.days) ? row.days[day] : 0))
155
+ return [{ week: (row.week as number) * 1000, days, commits: count(row.total), additions: null, deletions: null }]
156
+ }).toSorted((a, b) => a.week - b.week)
157
+ const first = weeks[0]?.week ?? Number.POSITIVE_INFINITY
158
+ const contributors = ((contributorRows ?? []) as ContributorRow[]).flatMap(row => {
159
+ const { login, id } = accountOf(row?.author)
160
+ const theirs = (row?.weeks ?? []).flatMap(item => {
161
+ const week = count(item?.w) * 1000
162
+ return week >= first && count(item?.c) > 0 ? [[week, count(item?.c), 0, 0] as [number, number, number, number]] : []
163
+ })
164
+ return login && theirs.length > 0 ? [{ login, id, name: null, weeks: theirs, commits: theirs.reduce((sum, item) => sum + item[1], 0) }] : []
165
+ }).toSorted((a, b) => b.commits - a.commits).slice(0, MAX_CONTRIBUTORS).map(({ login, id, name, weeks: theirs }) => ({ login, id, name, weeks: theirs }))
166
+ const punch = emptyPunch()
167
+ for (const row of (punchRows ?? []) as unknown[][]) {
168
+ if (Array.isArray(row) && Number.isSafeInteger(row[0]) && Number.isSafeInteger(row[1]) && punch[row[0] as number]?.[row[1] as number] !== undefined) {
169
+ punch[row[0] as number]![row[1] as number] = count(row[2])
170
+ }
171
+ }
172
+ return { weeks, contributors, punch, lines: null }
173
+ }
174
+
175
+ // The lines added and removed each week since `since`, and each
176
+ // contributor's, as GitHub's statistics count them; none, `lines` false,
177
+ // where it counts none (past 10,000 commits, where it counts each
178
+ // contributor's as none too).
179
+ export async function repositoryLines(fullName: string, token: string | null, since: number, fetchImpl: typeof fetch = globalThis.fetch): Promise<PulseLines> {
180
+ const [frequency = null, contributorRows = null] = await statistics(fullName, token, ['code_frequency', 'contributors'], fetchImpl)
181
+ if (frequency === null) return { weeks: [], contributors: [], lines: false }
182
+ const weeks = frequency.flatMap(row => Array.isArray(row) && Number.isSafeInteger(row[0]) && (row[0] as number) * 1000 >= since
183
+ ? [[(row[0] as number) * 1000, count(row[1]), count(Math.abs(Number(row[2])))] as [number, number, number]] : [])
184
+ const contributors = ((contributorRows ?? []) as ContributorRow[]).flatMap(row => {
185
+ const { login } = accountOf(row?.author)
186
+ const theirs = (row?.weeks ?? []).flatMap(item => count(item?.w) * 1000 >= since && count(item?.a) + count(item?.d) > 0
187
+ ? [[count(item?.w) * 1000, count(item?.a), count(item?.d)] as [number, number, number]] : [])
188
+ return login && theirs.length > 0 ? [{ login, weeks: theirs }] : []
189
+ })
190
+ return { weeks, contributors, lines: true }
191
+ }
192
+
193
+ // The default branch's head, null for a repository with none.
194
+ export async function defaultHead(fullName: string, token: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<string | null> {
195
+ const [owner, name] = fullName.split('/') as [string, string]
196
+ const data = await githubGraphql('query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { defaultBranchRef { target { oid } } } }',
197
+ { owner, name }, token, fetchImpl) as { repository?: { defaultBranchRef?: { target?: { oid?: unknown } | null } | null } | null } | null
198
+ if (!data?.repository) throw new GithubApiError(404, 'github-not-found')
199
+ const oid = data.repository.defaultBranchRef?.target?.oid
200
+ return typeof oid === 'string' && SHA.test(oid) ? oid : null
201
+ }
202
+
203
+ // The commits from `head` made since `since`, as the Commits tab reads and
204
+ // keeps them (`pages`, historyFrom): on from the parents of those since (or
205
+ // as much as YEAR_SKEW_MS before), not yet listed, YEAR_STARTS at a time,
206
+ // till none is left; the pages kept read
207
+ // of the store, those not of GitHub, as many queries of them as
208
+ // YEAR_QUERIES, the rest left for the next to ask (`complete` false, how
209
+ // many are `read`); more than YEAR_COMMITS, too many (`tooMany`). Read
210
+ // whole, those it was read on past besides (`passed`): undated, or dated
211
+ // before it, a clock behind, the branch's commits since beneath them. Read
212
+ // no further than `need` asks, those since it read whole (`complete`, `from`
213
+ // the newest of those read whose parents are not, every commit after it
214
+ // read; null, the year read whole).
215
+ export async function yearCommits(fullName: string, token: string, head: string, since: number, pages: HistoryPages,
216
+ fetchImpl: typeof fetch = globalThis.fetch, need = since): Promise<YearCommits> {
217
+ const found = new Map<string, HistoryCommit>()
218
+ const tried = new Set<string>()
219
+ const within = (commit: HistoryCommit) => commit.committedAt === null || commit.committedAt >= since - YEAR_SKEW_MS
220
+ const dated = (commit: HistoryCommit) => commit.committedAt !== null && commit.committedAt >= since
221
+ const read = () => [...found.values()].filter(dated).length
222
+ const passed = () => [...found.values()].filter(commit => !dated(commit) && within(commit))
223
+ let queries = 0
224
+ let starts = [head]
225
+ while (starts.length > 0) {
226
+ // Those not read yet, the head before any, else the parents of those
227
+ // read, no later than the newest of them (but for a clock behind): read
228
+ // whole after it.
229
+ const unread = new Set(starts)
230
+ const from = found.size === 0 ? Number.POSITIVE_INFINITY : Math.max(Number.NEGATIVE_INFINITY, ...[...found.values()]
231
+ .filter(commit => commit.parents.some(sha => unread.has(sha))).map(commit => commit.committedAt ?? Number.POSITIVE_INFINITY))
232
+ if (need > since && from < need) return { complete: true, commits: [...found.values()].filter(dated), passed: passed(), from }
233
+ if (queries >= YEAR_QUERIES) return { complete: false, read: read(), tooMany: false }
234
+ const batch = starts.slice(0, YEAR_STARTS)
235
+ for (const sha of batch) tried.add(sha)
236
+ const got = await historyFrom(fullName, token, batch, pages, fetchImpl)
237
+ if (got.read > 0) queries++
238
+ for (const commit of got.commits) if (!found.has(commit.sha)) found.set(commit.sha, commit)
239
+ if (read() > YEAR_COMMITS) return { complete: false, read: read(), tooMany: true }
240
+ starts = [...new Set([...found.values()].filter(within).flatMap(commit => commit.parents))].filter(sha => !found.has(sha) && !tried.has(sha))
241
+ }
242
+ return { complete: true, commits: [...found.values()].filter(dated), passed: passed(), from: null }
243
+ }
244
+
245
+ // Whether an author is a GitHub App's bot: by its login (`<app>[bot]`, as a
246
+ // commit's no-reply address names it, github-accounts.ts commitAccount),
247
+ // else, for one GitHub names no account for, by the name it commits as.
248
+ const bot = (login: string | null | undefined, name: string | null | undefined) => /\[bot\]$/iu.test(login ?? name ?? '')
249
+
250
+ // Whether an agent made `commit` or co-authored it: its author one of
251
+ // GitHub's agents (common/managed/github-agents.ts githubAgent), or any of
252
+ // its co-authors an agent (commit-coauthors.ts coauthorsOf), as Commits
253
+ // marks them, or any bot, whichever GitHub App's; or one an agent's pull request brought (`merges`,
254
+ // agent-merges.ts agentCommits); or one an account agents work as pushed to
255
+ // the branch directly (`direct`, github-agents.ts agentOperated).
256
+ const withAgent = (commit: HistoryCommit, merges: ReadonlySet<string>, direct: ReadonlySet<string>) => merges.has(commit.sha)
257
+ || (direct.has(commit.sha) && agentOperated(commit.authorLogin, commit.authorId ?? null))
258
+ || githubAgent(commit.authorLogin, commit.authorId ?? null, commit.authorName) !== null || bot(commit.authorLogin, commit.authorName)
259
+ || coauthorsOf(commit).some(coauthor => Boolean(coauthor.agent) || bot(coauthor.login, coauthor.name))
260
+
261
+ // The year from `first` (pulseSince(52, …)) of `commits`, as GitHub's
262
+ // statistics count them, merges left out: a week a row, its commits each day
263
+ // (UTC) and in all and the lines they added and removed, in all and each day,
264
+ // those that release the repository (`releases`, release-commits.ts) and,
265
+ // of the rest, those an agent made or co-authored, merged from an agent's
266
+ // pull request (`merges`) or pushed directly as an account agents work as
267
+ // (`direct`, withAgent), each day;
268
+ // its contributors,
269
+ // by account (its id, by the login it committed as first listed, the newest,
270
+ // whatever it was renamed from; a login's commits read before ids were, the
271
+ // one account's that committed as it, where only one did), else login, each
272
+ // matched as GitHub matches logins, regardless of case, else name (as many
273
+ // as MAX_CONTRIBUTORS, the most commits first),
274
+ // and their weeks and days, each day's releases and commits with agents too;
275
+ // and its commits by day of the week and hour, in each author's own time
276
+ // zone (else UTC, where GitHub told none), the year's and each period's
277
+ // (PULSE_WEEKS, its last weeks).
278
+ export function commitStats(commits: HistoryCommit[], first: number, merges: ReadonlySet<string> = new Set(), releases: ReadonlySet<string> = new Set(),
279
+ direct: ReadonlySet<string> = new Set()): PulseStats {
280
+ const weeks = Array.from({ length: 52 }, (_, at) => ({ week: first + at * WEEK_MS, days: Array.from({ length: 7 }, () => 0), commits: 0, additions: 0, deletions: 0,
281
+ dayLines: Array.from({ length: 7 }, () => [0, 0] as [number, number]), agentDays: Array.from({ length: 7 }, () => 0), releaseDays: Array.from({ length: 7 }, () => 0),
282
+ directDays: Array.from({ length: 7 }, () => 0) }))
283
+ type Counts = Map<number, number[]>
284
+ const people = new Map<string, { login: string | null; logins: Set<string>; id: number | null; name: string | null; weeks: Counts; days: Counts; commits: number }>()
285
+ const punches = Object.fromEntries(PULSE_WEEKS.map(span => [span, emptyPunch()]))
286
+ // Each commit's author, by the key their commits are counted by, and of a
287
+ // login an account took on, that account (`merged`).
288
+ const keys = new Map<string, string>()
289
+ const merged = new Map<string, { login: string | null; id: number | null; name: string | null }>()
290
+ const add = (counts: Counts, at: number, made: number[]) => counts.set(at, made.map((value, index) => (counts.get(at)?.[index] ?? 0) + value))
291
+ for (const commit of commits) {
292
+ if (commit.parents.length > 1 || commit.committedAt === null || commit.committedAt < first) continue
293
+ const at = Math.floor((commit.committedAt - first) / WEEK_MS)
294
+ const week = weeks[at]
295
+ if (!week) continue
296
+ const [additions, deletions] = [commit.additions ?? 0, commit.deletions ?? 0]
297
+ const weekday = new Date(commit.committedAt).getUTCDay()
298
+ week.days[weekday]!++
299
+ week.dayLines[weekday]![0] += additions
300
+ week.dayLines[weekday]![1] += deletions
301
+ week.commits++
302
+ week.additions += additions
303
+ week.deletions += deletions
304
+ const release = releases.has(commit.sha)
305
+ const agent = !release && withAgent(commit, merges, direct)
306
+ if (release) week.releaseDays[weekday]!++
307
+ if (direct.has(commit.sha)) week.directDays[weekday]!++
308
+ if (agent) week.agentDays[weekday]!++
309
+ const local = new Date(commit.authoredAt != null && commit.authorZone != null ? commit.authoredAt + commit.authorZone * 60_000 : commit.committedAt)
310
+ for (const span of PULSE_WEEKS) if (at >= 52 - span) punches[span]![local.getUTCDay()]![local.getUTCHours()]!++
311
+ const key = commit.authorLogin && commit.authorId != null ? `#${commit.authorId}` : commit.authorLogin ? `@${commit.authorLogin.toLowerCase()}`
312
+ : commit.authorName ? `=${commit.authorName}` : null
313
+ if (!key) continue
314
+ keys.set(commit.sha, key)
315
+ const person = people.get(key) ?? { login: commit.authorLogin, logins: new Set<string>(), id: commit.authorId ?? null, name: commit.authorName,
316
+ weeks: new Map(), days: new Map(), commits: 0 }
317
+ if (commit.authorLogin) person.logins.add(commit.authorLogin.toLowerCase())
318
+ add(person.weeks, week.week, [1, additions, deletions])
319
+ add(person.days, Math.floor(commit.committedAt / DAY_MS) * DAY_MS, [1, additions, deletions, agent ? 1 : 0, release ? 1 : 0, direct.has(commit.sha) ? 1 : 0])
320
+ person.commits++
321
+ people.set(key, person)
322
+ }
323
+ // A login one account committed as, those of its commits read before ids
324
+ // were that account's; one more did, theirs apart, no one's.
325
+ const accounts = Map.groupBy([...people.values()].filter(person => person.id !== null).flatMap(person => [...person.logins].map(login => ({ login, person }))),
326
+ ({ login }) => login)
327
+ for (const [key, person] of people) {
328
+ const [owner, ...others] = person.login !== null && person.id === null ? accounts.get(person.login.toLowerCase()) ?? [] : []
329
+ if (!owner || others.length > 0) continue
330
+ const account = owner.person
331
+ for (const [week, counts] of person.weeks) add(account.weeks, week, counts)
332
+ for (const [day, counts] of person.days) add(account.days, day, counts)
333
+ account.commits += person.commits
334
+ people.delete(key)
335
+ merged.set(key, account)
336
+ }
337
+ const rows = (counts: Counts) => [...counts].toSorted(([a], [b]) => a - b).map(([at, made]) => [at, ...made])
338
+ const contributors = [...people.values()].toSorted((a, b) => b.commits - a.commits).slice(0, MAX_CONTRIBUTORS).map(({ login, id, name, weeks: theirs, days }) => ({
339
+ login, id, name, weeks: rows(theirs) as [number, number, number, number][], days: rows(days) as [number, number, number, number, number, number, number][] }))
340
+ // Each pushed directly by the contributor its author's commits are counted
341
+ // as, whatever account it named.
342
+ const directCommits = commits.filter(commit => direct.has(commit.sha) && commit.committedAt !== null && commit.committedAt >= first)
343
+ .toSorted((a, b) => b.committedAt! - a.committedAt!).slice(0, DIRECT_LISTED).map((commit): PulseCommit => {
344
+ const { sha, committedAt, subject, authorLogin, authorId, authorName } = commit
345
+ const author = people.get(keys.get(sha) ?? '') ?? merged.get(keys.get(sha) ?? '') ?? { login: authorLogin, id: authorId ?? null, name: authorName }
346
+ const kind = releases.has(sha) ? 'release' : withAgent(commit, merges, direct) ? 'agent' : 'without'
347
+ return { sha, at: committedAt!, subject, login: author.login, id: author.id, name: author.name, kind }
348
+ })
349
+ return { weeks, contributors, punch: punches[52]!, punches, lines: true, directCommits }
350
+ }
351
+
352
+ // The year's statistics as a period of `weeks` weeks shows them: when its
353
+ // commits are made, the period's where they were counted from its commits
354
+ // (`periodPunch`), else the year's or more, as GitHub's statistics count it;
355
+ // the commits pushed to the branch directly, the period's alone.
356
+ export function periodStats(stats: PulseStats, weeks: number) {
357
+ const { punches, directCommits, ...rest } = stats
358
+ const punch = punches?.[weeks]
359
+ const since = stats.weeks.at(-weeks)?.week ?? Number.NEGATIVE_INFINITY
360
+ return { ...rest, punch: punch ?? rest.punch, periodPunch: Boolean(punch), ...directCommits ? { directCommits: directCommits.filter(commit => commit.at >= since) } : {} }
361
+ }
362
+
363
+ // GitHub's statistics of a year past YEAR_COMMITS (repositoryStats) with
364
+ // its `lines` added in, each week's and each contributor's, by login; null
365
+ // while GitHub is counting them, `lines` null.
366
+ export function withLines(stats: PulseStats, lines: PulseLines | null): PulseStats {
367
+ if (!lines) return { ...stats, lines: null }
368
+ if (!lines.lines) return { ...stats, lines: false }
369
+ const weekly = new Map(lines.weeks.map(([week, additions, deletions]) => [week, [additions, deletions]]))
370
+ const theirs = new Map(lines.contributors.map(person => [person.login, new Map(person.weeks.map(([week, additions, deletions]) => [week, [additions, deletions]]))]))
371
+ return {
372
+ ...stats,
373
+ weeks: stats.weeks.map(week => ({ ...week, additions: weekly.get(week.week)?.[0] ?? 0, deletions: weekly.get(week.week)?.[1] ?? 0 })),
374
+ contributors: stats.contributors.map(person => ({ ...person, weeks: person.weeks.map(([week, made]) => {
375
+ const added = person.login ? theirs.get(person.login)?.get(week) : undefined
376
+ return [week, made, added?.[0] ?? 0, added?.[1] ?? 0] as [number, number, number, number]
377
+ }) })),
378
+ lines: true,
379
+ }
380
+ }
381
+
382
+ // Where a period of `weeks` weeks starts, as GitHub's statistics count weeks:
383
+ // the Sunday (UTC) that many weeks back, this week the last of them.
384
+ export function pulseSince(weeks: number, now: number) {
385
+ const today = Math.floor(now / DAY_MS) * DAY_MS
386
+ const sunday = today - new Date(today).getUTCDay() * DAY_MS
387
+ return sunday - (weeks - 1) * WEEK_MS
388
+ }
389
+
390
+ const SEARCH = (name: string, fragment: string, first = 0) => `${name}: search(type: ISSUE, query: $${name}, first: ${Math.max(first, 1)}) {
391
+ issueCount${first > 0 ? ` nodes { ...${fragment} }` : ''}
392
+ }`
393
+ const ACTIVITY_QUERY = `query($merged: String!, $mergedBefore: String!, $opened: String!, $openedBefore: String!,
394
+ $closed: String!, $closedBefore: String!, $created: String!, $createdBefore: String!) {
395
+ ${SEARCH('merged', 'pull', SEARCHED)}
396
+ ${SEARCH('mergedBefore', 'pull')}
397
+ ${SEARCH('opened', 'pull', LISTED)}
398
+ ${SEARCH('openedBefore', 'pull')}
399
+ ${SEARCH('closed', 'issue', SEARCHED)}
400
+ ${SEARCH('closedBefore', 'issue')}
401
+ ${SEARCH('created', 'issue', LISTED)}
402
+ ${SEARCH('createdBefore', 'issue')}
403
+ }
404
+ fragment pull on PullRequest { number title pullState: state isDraft createdAt mergedAt author { ${ACTOR} } }
405
+ fragment issue on Issue { number title issueState: state stateReason createdAt closedAt author { ${ACTOR} } }`
406
+
407
+ type SearchNode = { number?: unknown; title?: unknown; pullState?: unknown; issueState?: unknown; isDraft?: unknown; stateReason?: unknown
408
+ createdAt?: unknown; mergedAt?: unknown; closedAt?: unknown; author?: GraphAccount } | null
409
+ type Search = { issueCount?: unknown; nodes?: SearchNode[] } | null | undefined
410
+ const REASONS: Record<string, string> = { COMPLETED: 'completed', NOT_PLANNED: 'not-planned', DUPLICATE: 'duplicate' }
411
+
412
+ // An issue or pull request a search lists, as the popup's preview of it
413
+ // takes one, `at` when it was merged, opened or closed.
414
+ function listed(node: SearchNode, at: unknown) {
415
+ if (!node || !Number.isSafeInteger(node.number) || (node.number as number) <= 0) return null
416
+ const title = typeof node.title === 'string' ? node.title.trim().slice(0, MAX_TITLE) : ''
417
+ const author = actorOf(node.author)
418
+ const common = { number: node.number as number, title, author: author.login, authorId: author.id, at: timeOf(at), createdAt: timeOf(node.createdAt) }
419
+ return node.pullState === undefined
420
+ ? { ...common, kind: 'issues', state: node.issueState === 'OPEN' ? 'open' : 'closed', reason: REASONS[String(node.stateReason)] ?? null }
421
+ : { ...common, kind: 'pulls', state: node.pullState === 'MERGED' ? 'merged' : node.pullState === 'OPEN' ? 'open' : 'closed', draft: node.isDraft === true }
422
+ }
423
+
424
+ // The pull requests merged and opened and the issues closed and opened since
425
+ // the start of the week `weeks` weeks back (`since`), and over the same days
426
+ // of the week `weeks` weeks earlier (`previous`, its count: a week's Sunday to
427
+ // Thursday beside the week before's, not the days just before, whose days of
428
+ // the week differ), the latest of each listed, as many as LISTED: those
429
+ // opened as GitHub sorts them, those merged or closed the latest of the
430
+ // SEARCHED most recently updated, which are the latest of all where the
431
+ // period holds no more, or the last listed was merged or closed after the
432
+ // SEARCHED-th was last updated. A search counts by the day (UTC). Narrowed
433
+ // (`narrowing`), those of its days from `from` to `to` (both days, UTC;
434
+ // none, on), and those its `authors` (GitHub logins) opened, any of them.
435
+ export async function repositoryActivity(fullName: string, token: string, weeks: number, now: number = Date.now(), fetchImpl: typeof fetch = globalThis.fetch,
436
+ narrowing: PulseNarrowing | null = null) {
437
+ const since = pulseSince(weeks, now)
438
+ const back = weeks * WEEK_MS
439
+ const day = (at: number) => new Date(at).toISOString().slice(0, 10)
440
+ const current = narrowing?.to == null ? `>=${day(narrowing?.from ?? since)}` : `${day(narrowing.from)}..${day(narrowing.to)}`
441
+ const previous = `${day(since - back)}..${day(now - back)}`
442
+ const repo = `repo:${fullName}`
443
+ // An app's login (`name[bot]`) is searched for as GitHub names its app.
444
+ const by = (narrowing?.authors ?? []).map(login => ` author:${login.endsWith('[bot]') ? `app/${login.slice(0, -5)}` : login}`).join('')
445
+ const asked = (what: string, range: string, sort: string) => `${repo} ${what}${range}${by} sort:${sort}`
446
+ const kinds = {
447
+ merged: ['is:pr is:merged merged:', 'updated-desc'], opened: ['is:pr created:', 'created-desc'],
448
+ closed: ['is:issue is:closed closed:', 'updated-desc'], created: ['is:issue created:', 'created-desc'],
449
+ } as const
450
+ const variables = Object.fromEntries(Object.entries(kinds).flatMap(([name, [what, sort]]) => [[name, asked(what, current, sort)],
451
+ [`${name}Before`, asked(what, previous, sort)]]))
452
+ const data = await githubGraphql(ACTIVITY_QUERY, variables, token, fetchImpl) as Record<string, Search> | null
453
+ const at = { merged: 'mergedAt', opened: 'createdAt', closed: 'closedAt', created: 'createdAt' } as const
454
+ const told = Object.fromEntries((Object.keys(kinds) as (keyof typeof kinds)[]).map(name => [name, {
455
+ count: count(data?.[name]?.issueCount),
456
+ previous: count(data?.[`${name}Before`]?.issueCount),
457
+ items: (data?.[name]?.nodes ?? []).flatMap(node => listed(node, node?.[at[name]]) ?? []).toSorted((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, LISTED),
458
+ }]))
459
+ return { since, previousSince: since - back, ...told }
460
+ }