@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,51 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { ManagedDb } from './db-methods.ts'
3
+ import type { ManagedComment } from '../common/managed/comments.ts'
4
+ import type { TriageEntryPatch } from '../common/managed/triage.ts'
5
+ import { triageWireEntry } from './triage-response.ts'
6
+
7
+ export interface ImportTriageSnapshot {
8
+ entry: TriageEntryPatch | null
9
+ comments: ManagedComment[]
10
+ version: string
11
+ }
12
+ export interface ImportTriageStore {
13
+ getImportTriage(ids: readonly string[]): Promise<Record<string, ImportTriageSnapshot>>
14
+ importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string, now: number): Promise<boolean>
15
+ }
16
+
17
+ // Raw store methods share one transaction for comparison, triage, and comments.
18
+ export function importTriageMethods(store: Pick<ManagedDb, 'getReport' | 'listTriage' | 'listComments' | 'setTriageEntries' | 'createComment'>): ImportTriageStore {
19
+ async function snapshot(ids: readonly string[]) {
20
+ const rows = new Map((await store.listTriage(ids)).map(row => [row.findingId, row]))
21
+ const comments = await store.listComments(ids)
22
+ return Object.fromEntries(ids.map(id => {
23
+ const row = rows.get(id)
24
+ const entry = row ? triageWireEntry(row) : null
25
+ const own = comments.filter(comment => comment.findingId === id)
26
+ const version = createHash('sha256').update(JSON.stringify([entry, row?.updatedAt, own])).digest('hex')
27
+ return [id, { entry, comments: own, version }]
28
+ }))
29
+ }
30
+ return {
31
+ getImportTriage: snapshot,
32
+ async importTriage(entries, expected, actor, reportId, now) {
33
+ // A report deleted after the HTTP membership check must not leave newly
34
+ // imported annotations behind. Check existence in the write transaction.
35
+ if (!reportId || !(await store.getReport(reportId))) return false
36
+ const current = await snapshot(entries.map(([id]) => id))
37
+ if (entries.some(([id]) => current[id]!.version !== expected[id])) return false
38
+ const changes: [string, TriageEntryPatch | null][] = []
39
+ for (const [id, value] of entries) {
40
+ const { comment, ...entry } = value ?? {}
41
+ changes.push([id, Object.keys(entry).length > 0 ? entry : null])
42
+ if (comment && !current[id]!.comments.some(existing => existing.body === comment)) {
43
+ await store.createComment({ findingId: id, body: comment, authorId: null, authorLogin: null,
44
+ actor, createdAt: null, updatedAt: null, reportId }, now)
45
+ }
46
+ }
47
+ await store.setTriageEntries(changes, actor.id, actor.login, now, reportId)
48
+ return true
49
+ },
50
+ }
51
+ }
@@ -0,0 +1,192 @@
1
+ // Managed HTTP app and standalone boot. The combined launcher mounts this
2
+ // same app on e2e's listener; storage, routing and cleanup stay here.
3
+ import { randomUUID } from 'node:crypto'
4
+ import { type Server, createServer } from 'node:http'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { createOriginGate } from '../server-common/origin.ts'
7
+ import { managedStorageLines } from '../server-common/storage-log.ts'
8
+ import { runReapers, withReap } from '../server-common/reap.ts'
9
+ import { startServer } from '../server-common/standalone.ts'
10
+ import { initializeApp } from '../server-common/initialize.ts'
11
+ import { type ManagedConfig, loadManagedConfig } from './config.ts'
12
+ import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
13
+ import { setNpmTarballCache } from './npm-loads.ts'
14
+ import { assembledReviewedDependencies } from './reviewed-dependencies.ts'
15
+ import { setNpmDocumentCache } from './npm-packages.ts'
16
+ import { loadManagedStatic } from './static.ts'
17
+ import { openManagedStorage } from './storage.ts'
18
+ import { setCacheDir } from '@preventive/upstream/npm.js'
19
+
20
+ // Housekeeping runs on ordinary traffic too, including serverless instances
21
+ // without a timer or cron. Failed sweeps retry on later traffic after a minute.
22
+ const REAP_INTERVAL_MS = 3_600_000
23
+ const REAP_RETRY_MS = 60_000
24
+
25
+ // `ui: false` serves only the API: `out/` is never read.
26
+ type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown' | 'reviewedDependencies'>> & { ui?: boolean }
27
+
28
+ export async function createManagedApp(config: ManagedConfig, options: ManagedAppOptions = {}) {
29
+ // Audits keep upstream's records on disk where the config has a cache
30
+ // directory; bundle builds set it in their own worker. The npm viewer
31
+ // keeps the tarballs it downloads there too, beside theirs, and public
32
+ // packages' whole documents (npm-packages.ts).
33
+ setCacheDir(config.upstreamCacheDir ?? false)
34
+ setNpmTarballCache(config.upstreamCacheDir ?? null)
35
+ setNpmDocumentCache(config.upstreamCacheDir ?? null)
36
+ // Load the optional peer only for serverless managed deployments. Embedding
37
+ // hosts can discard listener promises, so retain maintenance inside the app.
38
+ const waitUntil = config.serverless ? (await import('@vercel/functions')).waitUntil : undefined
39
+ // The host's reviewed dependencies, registered before; none after.
40
+ const reviewedDependencies = assembledReviewedDependencies()
41
+ const assembled = { ...options, ...reviewedDependencies ? { reviewedDependencies } : {} }
42
+ return await initializeApp(rollback => assembleManagedApp(config, assembled, rollback, waitUntil))
43
+ }
44
+
45
+ async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options }: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
46
+ const storage = await openManagedStorage(config)
47
+ const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage } = storage
48
+ rollback.defer(() => db.close())
49
+ const originGate = createOriginGate(config.host, config.trustProxyEnv)
50
+
51
+ let shuttingDown = false
52
+ const isShuttingDown = () => shuttingDown || options.isShuttingDown?.() === true
53
+ const inFlight = new Set<Promise<unknown>>()
54
+ function track(p: Promise<unknown>): void {
55
+ inFlight.add(p)
56
+ p.finally(() => inFlight.delete(p)).catch(() => {})
57
+ }
58
+
59
+ const serveStatic = ui ? loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
60
+ indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
61
+ }) : () => false
62
+ const routeRequest = createManagedRequestHandler({
63
+ ...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage, originGate, serveStatic,
64
+ ...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}), ...(waitUntil ? { waitUntil } : {}),
65
+ isShuttingDown, track,
66
+ })
67
+
68
+ let cleanup: Promise<void> | undefined, nextCleanupAt = 0
69
+ const maintenance = new AbortController()
70
+ function reap(automatic = false): Promise<void> {
71
+ if (cleanup) return cleanup
72
+ if (isShuttingDown()) return Promise.resolve()
73
+ const startedAt = Date.now()
74
+ nextCleanupAt = startedAt + REAP_INTERVAL_MS
75
+ let sessions = 0, uploads = 0
76
+ cleanup = (async () => {
77
+ const owner = automatic && config.serverless ? randomUUID() : null
78
+ if (owner && !await db.claimMaintenanceLease(owner, startedAt, startedAt + Math.max(300_000, (config.storageEncryptionMigrateMaxMs ?? 0) + 60_000), config.storageEncryptionMigrate)) return false
79
+ let succeeded = false
80
+ try {
81
+ await runReapers({
82
+ sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
83
+ ...(storage.reapUploads ? { uploads: async () => { uploads = await storage.reapUploads!(maintenance.signal) } } : {}),
84
+ ...('reapPrettyCopies' in storage ? { prettyCopies: () => storage.reapPrettyCopies(maintenance.signal) } : {}),
85
+ ...('reapStorage' in storage ? { storage: () => storage.reapStorage(maintenance.signal) } : {}),
86
+ })
87
+ succeeded = true
88
+ return true
89
+ } finally {
90
+ if (owner) await db.finishMaintenanceLease(owner, Date.now() + (succeeded ? REAP_INTERVAL_MS : REAP_RETRY_MS))
91
+ }
92
+ })().then(ran => {
93
+ nextCleanupAt = Date.now() + REAP_INTERVAL_MS
94
+ if (!ran) return
95
+ return console.info(`managed-reaper: removed ${sessions} expired session(s), ${uploads} stale upload part(s) in ${Date.now() - startedAt}ms`)
96
+ }, err => {
97
+ nextCleanupAt = Date.now() + REAP_RETRY_MS
98
+ throw err
99
+ }).finally(() => { cleanup = undefined })
100
+ track(cleanup)
101
+ return cleanup
102
+ }
103
+ function automaticReap(): Promise<void> {
104
+ if (isShuttingDown() || cleanup || Date.now() < nextCleanupAt) return Promise.resolve()
105
+ return reap(true).catch(err => console.warn('managed-reaper: cleanup failed:', err))
106
+ }
107
+ async function handleRequest(req: Parameters<typeof routeRequest>[0], res: Parameters<typeof routeRequest>[1]): Promise<void> {
108
+ // Register before routing can end the response, including when init()'s
109
+ // listener is mounted in Fastify rather than using api/managed.ts.
110
+ const pendingMaintenance = automaticReap(); waitUntil?.(pendingMaintenance)
111
+ try { await routeRequest(req, res) }
112
+ finally { await pendingMaintenance }
113
+ }
114
+ const gcTimer = config.serverless ? null : setInterval(() => {
115
+ void automaticReap()
116
+ }, REAP_INTERVAL_MS)
117
+ rollback.defer(stop)
118
+
119
+ function stop(): void {
120
+ shuttingDown = true
121
+ maintenance.abort()
122
+ if (gcTimer) clearInterval(gcTimer)
123
+ }
124
+
125
+ async function close(): Promise<void> {
126
+ stop()
127
+ if (inFlight.size > 0) await Promise.allSettled([...inFlight])
128
+ await db.close()
129
+ }
130
+ return { handleRequest, reap, isShuttingDown, stop, close }
131
+ }
132
+
133
+ export function logManagedStartup(config: ManagedConfig, port: number, mode = 'managed'): void {
134
+ console.log([
135
+ `DeepView managed server (${mode}):`,
136
+ ` HTTP: http://${config.host}:${port}/`,
137
+ ...managedStorageLines(config),
138
+ ].join('\n'))
139
+ }
140
+
141
+ export async function init(config = loadManagedConfig(), options: Pick<ManagedAppOptions, 'ui'> = {}): Promise<Server> {
142
+ const app = await createManagedApp(config, options)
143
+ const server = createServer(withReap(app.handleRequest, { managed: app.reap }, { isShuttingDown: app.isShuttingDown }))
144
+ let disposal: Promise<void> | undefined
145
+ function dispose(): Promise<void> {
146
+ if (!disposal) {
147
+ app.stop()
148
+ disposal = (async () => {
149
+ try {
150
+ if (server.listening) {
151
+ await new Promise<void>((resolve, reject) => {
152
+ server.close(err => { if (err) reject(err); else resolve() })
153
+ })
154
+ }
155
+ } finally {
156
+ try { await app.close() }
157
+ finally {
158
+ server.off('error', onError)
159
+ server.off('close', onClose)
160
+ server.off('listening', onListening)
161
+ }
162
+ }
163
+ })()
164
+ }
165
+ return disposal
166
+ }
167
+ // Embedding hosts can await full cleanup even when they only copied the
168
+ // request listener and never bound this server. Repeated disposal is safe.
169
+ server[Symbol.asyncDispose] = dispose
170
+
171
+ function onError(): void { onClose() }
172
+ function onClose(): void {
173
+ if (!disposal) void dispose().catch(err => { console.error('Managed server cleanup failed:', err) })
174
+ }
175
+ function onListening(): void {
176
+ const address = server.address()
177
+ const port = typeof address === 'object' && address ? address.port : config.port
178
+ logManagedStartup(config, port)
179
+ }
180
+ server.on('error', onError)
181
+ server.once('close', onClose)
182
+ server.on('listening', onListening)
183
+ return server
184
+ }
185
+
186
+ export async function start(): Promise<void> {
187
+ const config = loadManagedConfig()
188
+ const server = await init(config)
189
+ startServer(server, config)
190
+ }
191
+
192
+ if (import.meta.main) await start()
@@ -0,0 +1,37 @@
1
+ // What was lately worked out, kept a little while in memory for whoever asks
2
+ // for it next, by key: each for `keptMs` from when it was kept, the least
3
+ // lately read going first past `maxEntries` of them or `maxBytes` between
4
+ // them, and one that alone takes more than `maxBytes` not kept at all.
5
+ // Whoever reads one is the caller's to check, as they would one worked out.
6
+ export function keptBriefly<T>({ keptMs, maxBytes, maxEntries = Infinity }: { keptMs: number; maxBytes: number; maxEntries?: number }) {
7
+ const held = new Map<string, { value: T; bytes: number; timer: ReturnType<typeof setTimeout> }>()
8
+ let heldBytes = 0
9
+ const forget = (key: string) => {
10
+ const entry = held.get(key)
11
+ if (!entry) return
12
+ clearTimeout(entry.timer)
13
+ held.delete(key)
14
+ heldBytes -= entry.bytes
15
+ }
16
+ return {
17
+ get(key: string): T | undefined {
18
+ const entry = held.get(key)
19
+ if (!entry) return undefined
20
+ held.delete(key)
21
+ held.set(key, entry)
22
+ return entry.value
23
+ },
24
+ keep(key: string, value: T, bytes: number) {
25
+ if (bytes > maxBytes) return
26
+ forget(key)
27
+ for (const old of held.keys()) {
28
+ if (held.size < maxEntries && heldBytes + bytes <= maxBytes) break
29
+ forget(old)
30
+ }
31
+ const timer = setTimeout(() => { forget(key) }, keptMs)
32
+ timer.unref()
33
+ held.set(key, { value, bytes, timer })
34
+ heldBytes += bytes
35
+ },
36
+ }
37
+ }
@@ -0,0 +1,28 @@
1
+ // Answers kept for an hour, by what they answer (npm-insights.ts,
2
+ // npm-provenance.ts), at most `max` of them, the oldest asked going first;
3
+ // one still being asked for is shared, and one that failed, or that `whole`
4
+ // says is partial, is dropped once it is answered. `peek` gives one already
5
+ // answered, asking for nothing; undefined where none is.
6
+ const KEPT_MS = 60 * 60_000
7
+
8
+ export function kept<T>(max: number, whole: (value: T) => boolean = () => true) {
9
+ const answers = new Map<string, { at: number; value: Promise<T>; answered?: { value: T } }>()
10
+ const get = (key: string, ask: () => Promise<T>): Promise<T> => {
11
+ const now = Date.now()
12
+ const hit = answers.get(key)
13
+ if (hit && now - hit.at < KEPT_MS) return hit.value
14
+ const value = ask()
15
+ const entry: { at: number; value: Promise<T>; answered?: { value: T } } = { at: now, value }
16
+ answers.delete(key)
17
+ answers.set(key, entry)
18
+ while (answers.size > max) answers.delete(answers.keys().next().value!)
19
+ const drop = () => { if (answers.get(key) === entry) answers.delete(key) }
20
+ value.then(answer => { if (whole(answer)) entry.answered = { value: answer }; else drop(); return answer }, drop)
21
+ return value
22
+ }
23
+ const peek = (key: string): T | undefined => {
24
+ const hit = answers.get(key)
25
+ return hit?.answered && Date.now() - hit.at < KEPT_MS ? hit.answered.value : undefined
26
+ }
27
+ return Object.assign(get, { peek })
28
+ }
@@ -0,0 +1,162 @@
1
+ // Global deduplication reports live entirely in SQL. The payload is authenticated
2
+ // ciphertext bound to its row, using the deployment's existing storage key.
3
+ import { Buffer } from 'node:buffer'
4
+ import { createHash, randomUUID } from 'node:crypto'
5
+ import { type StorageKey, unwrapStorageValue, wrapStorageValue } from '../server-common/storage-crypto.ts'
6
+ import { parseLinkedFindings } from '../client/linked-findings.js'
7
+ import type { ManagedSql } from './sql.ts'
8
+ import { ManagedMutationError } from './management.ts'
9
+
10
+ export const LINK_REPORT_SCHEMA = `
11
+ CREATE TABLE IF NOT EXISTS managed_link_report (
12
+ id TEXT PRIMARY KEY, filename TEXT NOT NULL, sha256 TEXT NOT NULL UNIQUE,
13
+ encrypted_groups TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
14
+ group_count INTEGER NOT NULL, finding_count INTEGER NOT NULL,
15
+ uploaded_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
16
+ uploaded_by_login TEXT, uploaded_at INTEGER NOT NULL
17
+ ) STRICT;
18
+ `
19
+ export interface LinkReport {
20
+ id: string; filename: string; enabled: boolean; groupCount: number; findingCount: number
21
+ uploadedByLogin: string | null; uploadedAt: number
22
+ }
23
+ export interface LinkReportStore {
24
+ listLegacyLinkReports(): Promise<{ id: string; filename: string }[]>
25
+ migrateLinkReport(id: string, content: string): Promise<boolean>
26
+ listLinkReports(): Promise<LinkReport[]>
27
+ getLinkReport(sessionId: string, id: string): Promise<LinkReport & { groups: string[][] }>
28
+ getLinkRevision(): Promise<string>
29
+ getEnabledLinkGroups(revision: string): Promise<string[][]>
30
+ importLinkReport(sessionId: string, filename: string, content: string): Promise<{ report: LinkReport; reused: boolean }>
31
+ setLinkReportEnabled(sessionId: string, id: string, enabled: boolean): Promise<void>
32
+ }
33
+ const columns = `id, filename, enabled, group_count AS groupCount, finding_count AS findingCount,
34
+ uploaded_by_login AS uploadedByLogin, uploaded_at AS uploadedAt`
35
+ const identity = (id: string) => `managed_link_report.encrypted_groups:${id}`
36
+ function decryptGroups(key: StorageKey | null, id: string, payload: string): string[][] {
37
+ if (!key) throw new ManagedMutationError(503, 'storage-encryption-required')
38
+ const plain = unwrapStorageValue(key, identity(id), payload)
39
+ try { return JSON.parse(plain.toString('utf8')) as string[][] } finally { plain.fill(0) }
40
+ }
41
+ export async function linkRevision(db: ManagedSql): Promise<string> {
42
+ return linkRevisionOf(await db.prepare('SELECT id, sha256 FROM managed_link_report WHERE enabled = 1 ORDER BY id').all())
43
+ }
44
+ // The revision of the enabled link reports' `{ id, sha256 }` rows, by id,
45
+ // however they were read.
46
+ export function linkRevisionOf(rows: unknown[]): string {
47
+ return rows.length > 0 ? createHash('sha256').update(JSON.stringify(rows)).digest('base64url') : ''
48
+ }
49
+ function parseLinkReport(content: string) {
50
+ const parsed = parseLinkedFindings(content)
51
+ if (parsed) return parsed
52
+ // Older managed exports may carry the normalized links envelope.
53
+ try {
54
+ const data = JSON.parse(content)
55
+ if (data?.source !== 'links' || !Array.isArray(data.links) || !Array.isArray(data.findings) || data.findings.length > 0) return null
56
+ return data.links.length === 0 ? { groups: [], skipped: 0 } : parseLinkedFindings(JSON.stringify(data.links))
57
+ } catch { return null }
58
+ }
59
+ export function linkReportMethods(db: ManagedSql, key: StorageKey | null): LinkReportStore {
60
+ const map = (row: Omit<LinkReport, 'enabled'> & { enabled: number }): LinkReport => ({ ...row, enabled: row.enabled === 1 })
61
+ async function authorize(sessionId: string) {
62
+ const user = await db.prepare(`SELECT u.id, u.login, u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
63
+ WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, Date.now()) as { id: string; login: string; role: string } | undefined
64
+ if (!user) throw new ManagedMutationError(401, 'unauthenticated')
65
+ if (user.role !== 'admin') throw new ManagedMutationError(403, 'forbidden')
66
+ return user
67
+ }
68
+ return {
69
+ async listLegacyLinkReports() {
70
+ return await db.prepare("SELECT id, filename FROM managed_report WHERE analyzer = 'links' ORDER BY id").all() as { id: string; filename: string }[]
71
+ },
72
+ async migrateLinkReport(id, content) {
73
+ const row = await db.prepare(`SELECT filename, sha256, visible, uploaded_by AS uploadedBy,
74
+ uploaded_by_login AS uploadedByLogin, uploaded_at AS uploadedAt FROM managed_report WHERE id = ? AND analyzer = 'links'`).get(id) as {
75
+ filename: string; sha256: string; visible: number; uploadedBy: string | null; uploadedByLogin: string | null; uploadedAt: number
76
+ } | undefined
77
+ if (!row) return false
78
+ if (!key) throw new Error('Managed link reports require MANAGED_STORAGE_ENCRYPTION_KEY')
79
+ if (createHash('sha256').update(content).digest('base64url') !== row.sha256) throw new Error('Legacy link report content changed')
80
+ const parsed = parseLinkReport(content)
81
+ if (!parsed) throw new Error('Invalid legacy link report')
82
+ const plain = Buffer.from(JSON.stringify(parsed.groups))
83
+ try {
84
+ const sha256 = createHash('sha256').update(plain).digest('base64url')
85
+ await db.prepare(`INSERT INTO managed_link_report
86
+ (id, filename, sha256, encrypted_groups, enabled, group_count, finding_count, uploaded_by, uploaded_by_login, uploaded_at)
87
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(sha256) DO NOTHING`).run(
88
+ id, row.filename, sha256, wrapStorageValue(key, identity(id), plain), row.visible,
89
+ parsed.groups.length, new Set(parsed.groups.flat()).size, row.uploadedBy, row.uploadedByLogin, row.uploadedAt)
90
+ await db.prepare('DELETE FROM managed_report WHERE id = ?').run(id)
91
+ } finally { plain.fill(0) }
92
+ return true
93
+ },
94
+ async listLinkReports() {
95
+ return (await db.prepare(`SELECT ${columns} FROM managed_link_report ORDER BY uploaded_at DESC, id`).all() as Parameters<typeof map>[0][]).map(map)
96
+ },
97
+ async getLinkReport(sessionId, id) {
98
+ await authorize(sessionId)
99
+ const row = await db.prepare(`SELECT ${columns}, encrypted_groups AS payload FROM managed_link_report WHERE id = ?`).get(id) as (Parameters<typeof map>[0] & { payload: string }) | undefined
100
+ if (!row) throw new ManagedMutationError(404, 'no-link-report')
101
+ const { payload, ...report } = row
102
+ return { ...map(report), groups: decryptGroups(key, id, payload) }
103
+ },
104
+ getLinkRevision: () => linkRevision(db),
105
+ async getEnabledLinkGroups(revision) {
106
+ if (await linkRevision(db) !== revision) throw new ManagedMutationError(409, 'deduplication-changed')
107
+ const rows = await db.prepare('SELECT id, encrypted_groups AS payload FROM managed_link_report WHERE enabled = 1 ORDER BY id').all() as { id: string; payload: string }[]
108
+ if (rows.length > 0 && !key) throw new Error('Managed link reports require MANAGED_STORAGE_ENCRYPTION_KEY')
109
+ return rows.flatMap(row => decryptGroups(key, row.id, row.payload))
110
+ },
111
+ async importLinkReport(sessionId, filename, content) {
112
+ const user = await authorize(sessionId)
113
+ if (!key) throw new ManagedMutationError(503, 'storage-encryption-required')
114
+ const parsed = parseLinkReport(content)
115
+ if (!parsed || parsed.skipped) throw new ManagedMutationError(400, 'invalid-link-report')
116
+ const plain = Buffer.from(JSON.stringify(parsed.groups))
117
+ const sha256 = createHash('sha256').update(plain).digest('base64url')
118
+ try {
119
+ const existing = await db.prepare(`SELECT ${columns} FROM managed_link_report WHERE sha256 = ?`).get(sha256) as Parameters<typeof map>[0] | undefined
120
+ if (existing) return { report: map(existing), reused: true }
121
+ const id = randomUUID(), uploadedAt = Date.now()
122
+ const findingCount = new Set(parsed.groups.flat()).size, groupCount = parsed.groups.length
123
+ await db.prepare(`INSERT INTO managed_link_report
124
+ (id, filename, sha256, encrypted_groups, group_count, finding_count, uploaded_by, uploaded_by_login, uploaded_at)
125
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, filename, sha256, wrapStorageValue(key, identity(id), plain), groupCount, findingCount, user.id, user.login, uploadedAt)
126
+ return { report: { id, filename, enabled: true, groupCount, findingCount, uploadedByLogin: user.login, uploadedAt }, reused: false }
127
+ } finally { plain.fill(0) }
128
+ },
129
+ async setLinkReportEnabled(sessionId, id, enabled) {
130
+ await authorize(sessionId)
131
+ if (!(await db.prepare('UPDATE managed_link_report SET enabled = ? WHERE id = ?').run(+enabled, id)).changes) throw new ManagedMutationError(404, 'no-link-report')
132
+ },
133
+ }
134
+ }
135
+
136
+ // Merge complete components BEFORE projecting them onto the response. An ID
137
+ // absent from every returned report can still bridge two visible findings.
138
+ export function mergeLinkGroups(groups: readonly string[][], present?: ReadonlySet<string>): string[][] {
139
+ const parents = new Map<string, string>()
140
+ const root = (id: string): string => {
141
+ if (!parents.has(id)) parents.set(id, id)
142
+ let value = id
143
+ while (parents.get(value) !== value) {
144
+ parents.set(value, parents.get(parents.get(value)!)!)
145
+ value = parents.get(value)!
146
+ }
147
+ return value
148
+ }
149
+ for (const ids of groups) {
150
+ if (ids.length === 0) continue
151
+ const first = root(ids[0]!)
152
+ for (const id of ids.slice(1)) { const other = root(id); if (other !== first) parents.set(other, first) }
153
+ }
154
+ const merged = new Map<string, string[]>()
155
+ for (const id of parents.keys()) {
156
+ if (present && !present.has(id)) continue
157
+ const key = root(id)
158
+ const ids = merged.get(key) ?? []
159
+ ids.push(id); merged.set(key, ids)
160
+ }
161
+ return [...merged.values()].filter(ids => ids.length >= 2)
162
+ }
@@ -0,0 +1,39 @@
1
+ // A period's pull requests and issues, as the Pulse shows them
2
+ // (repository-pulse.ts repositoryActivity), told of those the repository's
3
+ // tabs keep (repository-pulls.ts readList) rather than searched for.
4
+ import { LISTED, type PulseNarrowing, pulseSince } from './repository-pulse.ts'
5
+ import type { KeptIssue, KeptPull } from './repository-pulls.ts'
6
+
7
+ const DAY_MS = 24 * 60 * 60_000
8
+ const WEEK_MS = 7 * DAY_MS
9
+
10
+ // What repositoryActivity tells, of the pull requests and issues kept
11
+ // (repository-pulls.ts readList: `pulls` and `issues`, those updated since
12
+ // the period before it started all read): each counted by the day (UTC), as
13
+ // a search counts it, its narrowing's `authors` by login whatever its case,
14
+ // an app's bot (`name[bot]`) its own, not a user's of its name, and the
15
+ // latest of each listed, as many
16
+ // as LISTED, those merged or closed the latest of all.
17
+ export function listedActivity(pulls: KeptPull[], issues: KeptIssue[], weeks: number, now: number = Date.now(), narrowing: PulseNarrowing | null = null) {
18
+ const since = pulseSince(weeks, now)
19
+ const back = weeks * WEEK_MS
20
+ const dayOf = (at: number) => Math.floor(at / DAY_MS) * DAY_MS
21
+ const [from, to] = [dayOf(narrowing?.from ?? since), narrowing?.to == null ? Number.POSITIVE_INFINITY : dayOf(narrowing.to) + DAY_MS]
22
+ const [before, beforeEnd] = [dayOf(since - back), dayOf(now - back) + DAY_MS]
23
+ // A login whatever its case, a bot's (`name[bot]`, as REST names it) apart from a user's of its name.
24
+ const loginOf = (login: string, bot = false) => `${login.toLowerCase().replace(/\[bot\]$/u, '')}${bot || /\[bot\]$/iu.test(login) ? '[bot]' : ''}`
25
+ const authors = new Set((narrowing?.authors ?? []).map(login => loginOf(login)))
26
+ const by = (row: KeptPull | KeptIssue) => authors.size === 0 || (row.author !== null && authors.has(loginOf(row.author, row.bot)))
27
+ const told = <T extends KeptPull | KeptIssue>(rows: T[], when: (row: T) => number | null, item: (row: T, at: number) => object) => {
28
+ const timed = rows.flatMap(row => { const at = when(row); return at !== null && by(row) ? [{ row, at }] : [] })
29
+ const within = timed.filter(({ at }) => at >= from && at < to)
30
+ return { count: within.length, previous: timed.filter(({ at }) => at >= before && at < beforeEnd).length,
31
+ items: within.toSorted((a, b) => b.at - a.at).slice(0, LISTED).map(({ row, at }) => item(row, at)) }
32
+ }
33
+ const common = (row: KeptPull | KeptIssue, at: number) => ({ number: row.number, title: row.title, author: row.author, authorId: row.authorId ?? null, at,
34
+ createdAt: row.createdAt })
35
+ const pull = (row: KeptPull, at: number) => ({ ...common(row, at), kind: 'pulls', state: row.state, draft: row.draft })
36
+ const issue = (row: KeptIssue, at: number) => ({ ...common(row, at), kind: 'issues', state: row.state, reason: row.reason })
37
+ return { since, previousSince: since - back, merged: told(pulls, row => row.merged?.at ?? null, pull), opened: told(pulls, row => row.createdAt, pull),
38
+ closed: told(issues, row => row.state === 'closed' ? row.closedAt : null, issue), created: told(issues, row => row.createdAt, issue) }
39
+ }