stackvitals 0.1.0__py3-none-any.whl

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 (465) hide show
  1. mergescout/__init__.py +1 -0
  2. mergescout/archive/__init__.py +0 -0
  3. mergescout/archive/__main__.py +30 -0
  4. mergescout/archive/convert.py +83 -0
  5. mergescout/archive/events.py +55 -0
  6. mergescout/archive/manifest.py +45 -0
  7. mergescout/archive/stream.py +83 -0
  8. mergescout/bench/__init__.py +1 -0
  9. mergescout/bench/__main__.py +135 -0
  10. mergescout/bench/ci.py +257 -0
  11. mergescout/bench/deploys.py +115 -0
  12. mergescout/bench/matched.py +22 -0
  13. mergescout/bench/prior.py +42 -0
  14. mergescout/bench/report.py +230 -0
  15. mergescout/bench/shared.py +112 -0
  16. mergescout/bench/stars.py +57 -0
  17. mergescout/cli.py +89 -0
  18. mergescout/config.py +35 -0
  19. mergescout/crawl/__init__.py +0 -0
  20. mergescout/crawl/__main__.py +97 -0
  21. mergescout/crawl/chain.py +34 -0
  22. mergescout/crawl/fetch.py +146 -0
  23. mergescout/crawl/outcomes.py +69 -0
  24. mergescout/crawl/queries.py +250 -0
  25. mergescout/crawl/runner.py +176 -0
  26. mergescout/crawl/seed.py +77 -0
  27. mergescout/crawl/state.py +169 -0
  28. mergescout/crawl/writer.py +87 -0
  29. mergescout/discover/__init__.py +0 -0
  30. mergescout/discover/__main__.py +58 -0
  31. mergescout/discover/sweep.py +286 -0
  32. mergescout/github/__init__.py +0 -0
  33. mergescout/github/graphql.py +202 -0
  34. mergescout/github/identities.py +35 -0
  35. mergescout/github/releases.py +137 -0
  36. mergescout/labels.py +20 -0
  37. mergescout/mcp_server.py +228 -0
  38. mergescout/measure/__init__.py +0 -0
  39. mergescout/measure/__main__.py +75 -0
  40. mergescout/measure/api_cost.py +91 -0
  41. mergescout/measure/count_repos.py +30 -0
  42. mergescout/measure/jsonl.py +14 -0
  43. mergescout/measure/report.py +56 -0
  44. mergescout/measure/sample.py +37 -0
  45. mergescout/owners.py +22 -0
  46. mergescout/setup_check.py +64 -0
  47. mergescout/signals/__init__.py +0 -0
  48. mergescout/signals/__main__.py +96 -0
  49. mergescout/signals/bump_ledger.py +258 -0
  50. mergescout/signals/bumps.py +104 -0
  51. mergescout/signals/check.py +141 -0
  52. mergescout/signals/community_bench.py +149 -0
  53. mergescout/signals/delivery_bench.py +103 -0
  54. mergescout/signals/deps_map.py +261 -0
  55. mergescout/signals/deps_trends.py +117 -0
  56. mergescout/signals/evidence.py +94 -0
  57. mergescout/signals/exploits.py +43 -0
  58. mergescout/signals/flow_bench.py +258 -0
  59. mergescout/signals/identity.py +73 -0
  60. mergescout/signals/issues.py +76 -0
  61. mergescout/signals/labels.py +79 -0
  62. mergescout/signals/osv.py +232 -0
  63. mergescout/signals/owners_watch.py +80 -0
  64. mergescout/signals/repo_stats.py +96 -0
  65. mergescout/signals/rules.py +70 -0
  66. mergescout/signals/silver.py +33 -0
  67. mergescout/signals/top_packages.py +26 -0
  68. mergescout/signals/triage.py +71 -0
  69. mergescout/signals/triage_math.py +120 -0
  70. mergescout/signals/walls.py +91 -0
  71. mergescout/snapshot/__init__.py +0 -0
  72. mergescout/snapshot/__main__.py +215 -0
  73. mergescout/snapshot/build.py +147 -0
  74. mergescout/snapshot/publish.py +70 -0
  75. mergescout/snapshot/schema.py +34 -0
  76. mergescout/snapshot/score.py +30 -0
  77. mergescout/store/__init__.py +0 -0
  78. mergescout/store/__main__.py +46 -0
  79. mergescout/store/sync.py +148 -0
  80. mergescout/timeutil.py +11 -0
  81. mergescout/tool/__init__.py +0 -0
  82. mergescout/tool/action_pack.py +62 -0
  83. mergescout/tool/live.py +201 -0
  84. mergescout/tool/queries.py +293 -0
  85. mergescout/tool/refs.py +31 -0
  86. mergescout/tool/store.py +129 -0
  87. mergescout/tool/tracking.py +83 -0
  88. stackvitals/__init__.py +2 -0
  89. stackvitals/__main__.py +4 -0
  90. stackvitals/ci/__init__.py +1 -0
  91. stackvitals/ci/action.py +574 -0
  92. stackvitals/ci/analyze.py +577 -0
  93. stackvitals/ci/api.py +218 -0
  94. stackvitals/ci/automation.py +212 -0
  95. stackvitals/ci/bench.py +195 -0
  96. stackvitals/ci/burnin.py +102 -0
  97. stackvitals/ci/cli.py +570 -0
  98. stackvitals/ci/collect.py +239 -0
  99. stackvitals/ci/cost.py +107 -0
  100. stackvitals/ci/coverage.py +155 -0
  101. stackvitals/ci/feedback.py +699 -0
  102. stackvitals/ci/fingerprint.py +220 -0
  103. stackvitals/ci/gitlab.py +121 -0
  104. stackvitals/ci/gitlab_action.py +125 -0
  105. stackvitals/ci/impact.py +125 -0
  106. stackvitals/ci/investigate.py +59 -0
  107. stackvitals/ci/kinds.py +52 -0
  108. stackvitals/ci/monitors.py +105 -0
  109. stackvitals/ci/owners.py +116 -0
  110. stackvitals/ci/quarantine_runtime.py +90 -0
  111. stackvitals/ci/report.py +435 -0
  112. stackvitals/ci/retry.py +67 -0
  113. stackvitals/ci/selfheal.py +102 -0
  114. stackvitals/ci/shards.py +120 -0
  115. stackvitals/ci/share.py +59 -0
  116. stackvitals/ci/store.py +93 -0
  117. stackvitals/ci/testreports.py +351 -0
  118. stackvitals/ci/tickets.py +106 -0
  119. stackvitals/ci/track_record.json +11 -0
  120. stackvitals/ci/triage.py +130 -0
  121. stackvitals/ci/triggers.py +147 -0
  122. stackvitals/ci/upload.py +85 -0
  123. stackvitals/ci/validate.py +91 -0
  124. stackvitals/cli.py +111 -0
  125. stackvitals/codeowners.py +69 -0
  126. stackvitals/community/__init__.py +0 -0
  127. stackvitals/community/action.py +140 -0
  128. stackvitals/community/affiliation.py +51 -0
  129. stackvitals/community/analyze.py +507 -0
  130. stackvitals/community/build.py +56 -0
  131. stackvitals/community/chaoss.py +70 -0
  132. stackvitals/community/cli.py +79 -0
  133. stackvitals/community/collect.py +240 -0
  134. stackvitals/community/context.py +54 -0
  135. stackvitals/community/report.py +250 -0
  136. stackvitals/connectors/__init__.py +244 -0
  137. stackvitals/connectors/ai_spend.py +190 -0
  138. stackvitals/connectors/base.py +82 -0
  139. stackvitals/connectors/check.py +686 -0
  140. stackvitals/connectors/copilot.py +104 -0
  141. stackvitals/connectors/discord.py +103 -0
  142. stackvitals/connectors/discourse.py +117 -0
  143. stackvitals/connectors/incident_io.py +106 -0
  144. stackvitals/connectors/jira.py +210 -0
  145. stackvitals/connectors/linear.py +140 -0
  146. stackvitals/connectors/pagerduty.py +96 -0
  147. stackvitals/connectors/slack.py +110 -0
  148. stackvitals/connectors/sonar.py +77 -0
  149. stackvitals/connectors/store.py +120 -0
  150. stackvitals/connectors/tracker.py +108 -0
  151. stackvitals/connectors/writeback.py +105 -0
  152. stackvitals/contribute/__init__.py +1 -0
  153. stackvitals/contribute/brief.py +297 -0
  154. stackvitals/contribute/cli.py +64 -0
  155. stackvitals/core/__init__.py +1 -0
  156. stackvitals/core/actors.py +103 -0
  157. stackvitals/core/agent_ledger.py +120 -0
  158. stackvitals/core/agreements.py +95 -0
  159. stackvitals/core/ai_outcomes.py +276 -0
  160. stackvitals/core/alerts.py +224 -0
  161. stackvitals/core/appdata.py +112 -0
  162. stackvitals/core/bucket.py +190 -0
  163. stackvitals/core/bugs.py +43 -0
  164. stackvitals/core/calendar.py +34 -0
  165. stackvitals/core/capex.py +57 -0
  166. stackvitals/core/change_risk.py +181 -0
  167. stackvitals/core/changes.py +133 -0
  168. stackvitals/core/chat.py +143 -0
  169. stackvitals/core/config.py +373 -0
  170. stackvitals/core/dataflow.py +168 -0
  171. stackvitals/core/decisions.py +275 -0
  172. stackvitals/core/delivery.py +954 -0
  173. stackvitals/core/delivery_peers.py +98 -0
  174. stackvitals/core/delivery_report.py +429 -0
  175. stackvitals/core/deliverycli.py +147 -0
  176. stackvitals/core/devlake_dora.sql +485 -0
  177. stackvitals/core/digest.py +333 -0
  178. stackvitals/core/explaincli.py +107 -0
  179. stackvitals/core/explore.py +296 -0
  180. stackvitals/core/export.py +342 -0
  181. stackvitals/core/exportcli.py +117 -0
  182. stackvitals/core/findings.py +350 -0
  183. stackvitals/core/health.py +94 -0
  184. stackvitals/core/identity.py +82 -0
  185. stackvitals/core/insights.py +365 -0
  186. stackvitals/core/insightscli.py +43 -0
  187. stackvitals/core/installdoc.py +69 -0
  188. stackvitals/core/investment.py +187 -0
  189. stackvitals/core/joins.py +43 -0
  190. stackvitals/core/llmsdoc.py +40 -0
  191. stackvitals/core/merge_queue.py +70 -0
  192. stackvitals/core/metrics.py +109 -0
  193. stackvitals/core/model.py +285 -0
  194. stackvitals/core/projectcli.py +66 -0
  195. stackvitals/core/propose.py +86 -0
  196. stackvitals/core/pulse.py +85 -0
  197. stackvitals/core/recorded.py +145 -0
  198. stackvitals/core/releases.py +39 -0
  199. stackvitals/core/requests.py +528 -0
  200. stackvitals/core/resync.py +35 -0
  201. stackvitals/core/schema.py +205 -0
  202. stackvitals/core/services.py +203 -0
  203. stackvitals/core/settings.py +96 -0
  204. stackvitals/core/sqlcli.py +111 -0
  205. stackvitals/core/stats.py +222 -0
  206. stackvitals/core/store.py +125 -0
  207. stackvitals/core/summary.py +104 -0
  208. stackvitals/core/time.py +29 -0
  209. stackvitals/core/tomledit.py +149 -0
  210. stackvitals/core/uicli.py +138 -0
  211. stackvitals/core/webapp.py +79 -0
  212. stackvitals/core/what_changed.py +76 -0
  213. stackvitals/core/workspace.py +194 -0
  214. stackvitals/deps/__init__.py +1 -0
  215. stackvitals/deps/action.py +299 -0
  216. stackvitals/deps/actions_vulns.py +89 -0
  217. stackvitals/deps/cli.py +77 -0
  218. stackvitals/deps/dossier.py +341 -0
  219. stackvitals/deps/evidence.py +44 -0
  220. stackvitals/deps/fixplan.py +40 -0
  221. stackvitals/deps/guarddog.py +32 -0
  222. stackvitals/deps/hygiene.py +47 -0
  223. stackvitals/deps/imports.py +113 -0
  224. stackvitals/deps/lockfiles.py +198 -0
  225. stackvitals/deps/lookalike.py +80 -0
  226. stackvitals/deps/reach.py +59 -0
  227. stackvitals/deps/release.py +110 -0
  228. stackvitals/deps/runtimes.py +161 -0
  229. stackvitals/deps/supply.py +454 -0
  230. stackvitals/deps/vulns.py +588 -0
  231. stackvitals/editors/__init__.py +40 -0
  232. stackvitals/editors/vscode/extension.js +101 -0
  233. stackvitals/editors/vscode/marks.js +92 -0
  234. stackvitals/editors/vscode/package.json +26 -0
  235. stackvitals/flow/__init__.py +1 -0
  236. stackvitals/flow/action.py +178 -0
  237. stackvitals/flow/analyze.py +418 -0
  238. stackvitals/flow/automate.py +72 -0
  239. stackvitals/flow/build.py +46 -0
  240. stackvitals/flow/cli.py +93 -0
  241. stackvitals/flow/code_hosts.py +146 -0
  242. stackvitals/flow/collect.py +451 -0
  243. stackvitals/flow/gitlab_mr.py +75 -0
  244. stackvitals/flow/knowledge.py +202 -0
  245. stackvitals/flow/peers.py +90 -0
  246. stackvitals/flow/preflight.py +210 -0
  247. stackvitals/flow/report.py +253 -0
  248. stackvitals/flow/rules.py +135 -0
  249. stackvitals/flow/store.py +108 -0
  250. stackvitals/flow/survival.py +84 -0
  251. stackvitals/flow/triage.py +62 -0
  252. stackvitals/mcp_server.py +670 -0
  253. stackvitals/portal/__init__.py +0 -0
  254. stackvitals/portal/action.py +205 -0
  255. stackvitals/portal/batch.py +80 -0
  256. stackvitals/portal/campaigns.py +104 -0
  257. stackvitals/portal/catalog.py +104 -0
  258. stackvitals/portal/checks.py +63 -0
  259. stackvitals/portal/cli.py +172 -0
  260. stackvitals/portal/descriptor.py +105 -0
  261. stackvitals/portal/export.py +43 -0
  262. stackvitals/portal/github.py +48 -0
  263. stackvitals/portal/golden.py +95 -0
  264. stackvitals/portal/history.py +30 -0
  265. stackvitals/portal/inventory.py +264 -0
  266. stackvitals/portal/owners.py +78 -0
  267. stackvitals/portal/pages.py +153 -0
  268. stackvitals/portal/rollup.py +32 -0
  269. stackvitals/portal/scorecard.py +366 -0
  270. stackvitals/security/__init__.py +2 -0
  271. stackvitals/security/probes.py +154 -0
  272. stackvitals/security/repo_checks.py +132 -0
  273. stackvitals/security/workflows.py +111 -0
  274. stackvitals/shell.py +107 -0
  275. stackvitals/site.py +51 -0
  276. stackvitals/web/app/LICENSES.md +428 -0
  277. stackvitals/web/app/boot.js +1 -0
  278. stackvitals/web/app/chunks/AddRepo-KRPZXWCY.js +1 -0
  279. stackvitals/web/app/chunks/SetupRunning-XWFQAKRZ.js +1 -0
  280. stackvitals/web/app/chunks/accuracy-data-OQTDXI7Y.js +1 -0
  281. stackvitals/web/app/chunks/agents-data-BAL6QAOF.js +11 -0
  282. stackvitals/web/app/chunks/chunk-24LFKFZ6.js +1 -0
  283. stackvitals/web/app/chunks/chunk-3G4SQNUC.js +1 -0
  284. stackvitals/web/app/chunks/chunk-3IXWEOIJ.js +154 -0
  285. stackvitals/web/app/chunks/chunk-3ROQFHPV.js +1 -0
  286. stackvitals/web/app/chunks/chunk-45PGAQRO.js +8 -0
  287. stackvitals/web/app/chunks/chunk-4H53OYLG.js +1 -0
  288. stackvitals/web/app/chunks/chunk-4LUI7O5P.js +1 -0
  289. stackvitals/web/app/chunks/chunk-4SQPQ5TB.js +1 -0
  290. stackvitals/web/app/chunks/chunk-4WKQTWUC.js +2 -0
  291. stackvitals/web/app/chunks/chunk-576YHDEK.js +8 -0
  292. stackvitals/web/app/chunks/chunk-6BDBXA6V.js +1 -0
  293. stackvitals/web/app/chunks/chunk-6RLVD2DT.js +1 -0
  294. stackvitals/web/app/chunks/chunk-763NH7PP.js +1 -0
  295. stackvitals/web/app/chunks/chunk-76WAVKUG.js +1 -0
  296. stackvitals/web/app/chunks/chunk-7II2BD5F.js +1 -0
  297. stackvitals/web/app/chunks/chunk-7TJCBOAY.js +1 -0
  298. stackvitals/web/app/chunks/chunk-ASYNLVOP.js +1 -0
  299. stackvitals/web/app/chunks/chunk-CTKDYZMG.js +1 -0
  300. stackvitals/web/app/chunks/chunk-CVB26NMC.js +1 -0
  301. stackvitals/web/app/chunks/chunk-G7NGB5UX.js +1 -0
  302. stackvitals/web/app/chunks/chunk-GPVLJRZD.js +1 -0
  303. stackvitals/web/app/chunks/chunk-GQYFN4PE.js +75 -0
  304. stackvitals/web/app/chunks/chunk-GT4EAKAO.js +2 -0
  305. stackvitals/web/app/chunks/chunk-IYMBFPCV.js +1 -0
  306. stackvitals/web/app/chunks/chunk-LAAGCPU3.js +1 -0
  307. stackvitals/web/app/chunks/chunk-MAPYGK3P.js +1 -0
  308. stackvitals/web/app/chunks/chunk-P5TXDD45.js +1 -0
  309. stackvitals/web/app/chunks/chunk-PABICGBL.js +1 -0
  310. stackvitals/web/app/chunks/chunk-RPXHAG6F.js +1 -0
  311. stackvitals/web/app/chunks/chunk-RQZPSJ2Y.js +1 -0
  312. stackvitals/web/app/chunks/chunk-S2ZBMEVA.js +1 -0
  313. stackvitals/web/app/chunks/chunk-S654BBEP.js +6 -0
  314. stackvitals/web/app/chunks/chunk-T7QVTNXF.js +1 -0
  315. stackvitals/web/app/chunks/chunk-THFUOCPC.js +273 -0
  316. stackvitals/web/app/chunks/chunk-TO4VVRZ7.js +1 -0
  317. stackvitals/web/app/chunks/chunk-VQ5YXO54.js +1 -0
  318. stackvitals/web/app/chunks/chunk-VWKSMFWJ.js +1 -0
  319. stackvitals/web/app/chunks/chunk-Y6LUXDM6.js +1 -0
  320. stackvitals/web/app/chunks/chunk-YTANGYYH.js +1 -0
  321. stackvitals/web/app/chunks/connect-KONYWYTT.js +1 -0
  322. stackvitals/web/app/chunks/connections-data-2DYS4UVW.js +32 -0
  323. stackvitals/web/app/chunks/logos-N5IINUAM.js +1 -0
  324. stackvitals/web/app/chunks/more-KMJMJQD3.js +10 -0
  325. stackvitals/web/app/chunks/more-PC6FVS3P.js +1 -0
  326. stackvitals/web/app/chunks/more-RCIVCUDE.js +2 -0
  327. stackvitals/web/app/chunks/more-ZKKRY6NO.css +1 -0
  328. stackvitals/web/app/chunks/sealbox-35YL4EUH.js +3 -0
  329. stackvitals/web/app/chunks/session-DYYTOE2K.js +1 -0
  330. stackvitals/web/app/chunks/settingsApply-2HRJEZYM.js +1 -0
  331. stackvitals/web/app/chunks/settingsReview-GX2FZQ4M.js +3 -0
  332. stackvitals/web/app/chunks/uPlot.esm-OIWT3AHJ.js +3 -0
  333. stackvitals/web/app/demo/ci/impact.json +1 -0
  334. stackvitals/web/app/demo/ci/quarantine.json +1 -0
  335. stackvitals/web/app/demo/ci/tickets.json +1 -0
  336. stackvitals/web/app/demo/mobile/alerts.json +1 -0
  337. stackvitals/web/app/demo/mobile/app.json +1 -0
  338. stackvitals/web/app/demo/mobile/community.json +1 -0
  339. stackvitals/web/app/demo/mobile/connectors.json +1 -0
  340. stackvitals/web/app/demo/mobile/data.json +1 -0
  341. stackvitals/web/app/demo/mobile/decisions.json +1 -0
  342. stackvitals/web/app/demo/mobile/delivery.json +1 -0
  343. stackvitals/web/app/demo/mobile/deps.json +1 -0
  344. stackvitals/web/app/demo/mobile/flow.json +1 -0
  345. stackvitals/web/app/demo/mobile/health.json +1 -0
  346. stackvitals/web/app/demo/mobile/insights.json +1 -0
  347. stackvitals/web/app/demo/mobile/pulse.json +1 -0
  348. stackvitals/web/app/demo/mobile/security.json +1 -0
  349. stackvitals/web/app/demo/mobile/summary/ci_jobs.jsonl +5 -0
  350. stackvitals/web/app/demo/mobile/summary/devlake_project_pr_metrics.jsonl +14 -0
  351. stackvitals/web/app/demo/mobile/summary/devlake_pull_requests.jsonl +14 -0
  352. stackvitals/web/app/demo/mobile/summary/manifest.json +1 -0
  353. stackvitals/web/app/demo/mobile/summary/prs.jsonl +19 -0
  354. stackvitals/web/app/demo/mobile/summary/tests.jsonl +1 -0
  355. stackvitals/web/app/demo/mobile/supply.json +1 -0
  356. stackvitals/web/app/demo/mobile/vulns.json +1 -0
  357. stackvitals/web/app/demo/payments-api/alerts.json +1 -0
  358. stackvitals/web/app/demo/payments-api/app.json +1 -0
  359. stackvitals/web/app/demo/payments-api/community.json +1 -0
  360. stackvitals/web/app/demo/payments-api/connectors.json +1 -0
  361. stackvitals/web/app/demo/payments-api/data.json +1 -0
  362. stackvitals/web/app/demo/payments-api/decisions.json +1 -0
  363. stackvitals/web/app/demo/payments-api/delivery.json +1 -0
  364. stackvitals/web/app/demo/payments-api/deps.json +1 -0
  365. stackvitals/web/app/demo/payments-api/flow.json +1 -0
  366. stackvitals/web/app/demo/payments-api/health.json +1 -0
  367. stackvitals/web/app/demo/payments-api/insights.json +1 -0
  368. stackvitals/web/app/demo/payments-api/pulse.json +1 -0
  369. stackvitals/web/app/demo/payments-api/security.json +1 -0
  370. stackvitals/web/app/demo/payments-api/summary/ci_jobs.jsonl +5 -0
  371. stackvitals/web/app/demo/payments-api/summary/devlake_project_pr_metrics.jsonl +14 -0
  372. stackvitals/web/app/demo/payments-api/summary/devlake_pull_requests.jsonl +14 -0
  373. stackvitals/web/app/demo/payments-api/summary/manifest.json +1 -0
  374. stackvitals/web/app/demo/payments-api/summary/prs.jsonl +22 -0
  375. stackvitals/web/app/demo/payments-api/summary/tests.jsonl +3 -0
  376. stackvitals/web/app/demo/payments-api/supply.json +1 -0
  377. stackvitals/web/app/demo/payments-api/vulns.json +1 -0
  378. stackvitals/web/app/demo/portal.json +1 -0
  379. stackvitals/web/app/demo/settings.json +1 -0
  380. stackvitals/web/app/demo/web-app/alerts.json +1 -0
  381. stackvitals/web/app/demo/web-app/app.json +1 -0
  382. stackvitals/web/app/demo/web-app/community.json +1 -0
  383. stackvitals/web/app/demo/web-app/connectors.json +1 -0
  384. stackvitals/web/app/demo/web-app/data.json +1 -0
  385. stackvitals/web/app/demo/web-app/decisions.json +1 -0
  386. stackvitals/web/app/demo/web-app/delivery.json +1 -0
  387. stackvitals/web/app/demo/web-app/deps.json +1 -0
  388. stackvitals/web/app/demo/web-app/flow.json +1 -0
  389. stackvitals/web/app/demo/web-app/health.json +1 -0
  390. stackvitals/web/app/demo/web-app/insights.json +1 -0
  391. stackvitals/web/app/demo/web-app/pulse.json +1 -0
  392. stackvitals/web/app/demo/web-app/security.json +1 -0
  393. stackvitals/web/app/demo/web-app/summary/ci_jobs.jsonl +5 -0
  394. stackvitals/web/app/demo/web-app/summary/devlake_project_pr_metrics.jsonl +14 -0
  395. stackvitals/web/app/demo/web-app/summary/devlake_pull_requests.jsonl +14 -0
  396. stackvitals/web/app/demo/web-app/summary/manifest.json +1 -0
  397. stackvitals/web/app/demo/web-app/summary/prs.jsonl +18 -0
  398. stackvitals/web/app/demo/web-app/summary/tests.jsonl +2 -0
  399. stackvitals/web/app/demo/web-app/supply.json +1 -0
  400. stackvitals/web/app/demo/web-app/vulns.json +1 -0
  401. stackvitals/web/app/fonts/plex-mono-400.woff2 +0 -0
  402. stackvitals/web/app/fonts/plex-sans-400.woff2 +0 -0
  403. stackvitals/web/app/fonts/plex-sans-600.woff2 +0 -0
  404. stackvitals/web/app/fonts/plex-sans-condensed-700.woff2 +0 -0
  405. stackvitals/web/app/help/accuracy.json +1 -0
  406. stackvitals/web/app/help/connectors.json +1 -0
  407. stackvitals/web/app/help/getting-started.json +1 -0
  408. stackvitals/web/app/help/index.json +1 -0
  409. stackvitals/web/app/help/modules.json +1 -0
  410. stackvitals/web/app/help/privacy-and-security.json +1 -0
  411. stackvitals/web/app/help/reference.json +1 -0
  412. stackvitals/web/app/main.css +1 -0
  413. stackvitals/web/app/main.js +1 -0
  414. stackvitals/web/app/pages/about.css +1 -0
  415. stackvitals/web/app/pages/about.js +1 -0
  416. stackvitals/web/app/pages/activity.css +1 -0
  417. stackvitals/web/app/pages/activity.js +1 -0
  418. stackvitals/web/app/pages/agents.css +1 -0
  419. stackvitals/web/app/pages/agents.js +2 -0
  420. stackvitals/web/app/pages/ci.css +1 -0
  421. stackvitals/web/app/pages/ci.js +1 -0
  422. stackvitals/web/app/pages/community.css +1 -0
  423. stackvitals/web/app/pages/community.js +1 -0
  424. stackvitals/web/app/pages/connections.css +1 -0
  425. stackvitals/web/app/pages/connections.js +6 -0
  426. stackvitals/web/app/pages/delivery.css +1 -0
  427. stackvitals/web/app/pages/delivery.js +45 -0
  428. stackvitals/web/app/pages/deps.css +1 -0
  429. stackvitals/web/app/pages/deps.js +7 -0
  430. stackvitals/web/app/pages/explore.css +1 -0
  431. stackvitals/web/app/pages/explore.js +7 -0
  432. stackvitals/web/app/pages/health.css +1 -0
  433. stackvitals/web/app/pages/health.js +1 -0
  434. stackvitals/web/app/pages/help.css +1 -0
  435. stackvitals/web/app/pages/help.js +6 -0
  436. stackvitals/web/app/pages/insights.css +1 -0
  437. stackvitals/web/app/pages/insights.js +1 -0
  438. stackvitals/web/app/pages/investment.css +1 -0
  439. stackvitals/web/app/pages/investment.js +3 -0
  440. stackvitals/web/app/pages/overview.css +1 -0
  441. stackvitals/web/app/pages/overview.js +1 -0
  442. stackvitals/web/app/pages/portal.css +1 -0
  443. stackvitals/web/app/pages/portal.js +51 -0
  444. stackvitals/web/app/pages/prs.css +1 -0
  445. stackvitals/web/app/pages/prs.js +4 -0
  446. stackvitals/web/app/pages/settings.css +1 -0
  447. stackvitals/web/app/pages/settings.js +1 -0
  448. stackvitals/web/app/pages/setup.css +1 -0
  449. stackvitals/web/app/pages/setup.js +2 -0
  450. stackvitals/web/app/pages/standing.css +1 -0
  451. stackvitals/web/app/pages/standing.js +1 -0
  452. stackvitals/web/app/pages/welcome-sheet.js +1 -0
  453. stackvitals/web/app/pages/welcome.js +1 -0
  454. stackvitals/web/app/settings-workflow.json +1 -0
  455. stackvitals/web/index.html +34 -0
  456. stackvitals/web/site/index.html +108 -0
  457. stackvitals/web/site/integrations.html +15 -0
  458. stackvitals/web/site/setup.html +15 -0
  459. stackvitals/web/site/site.css +68 -0
  460. stackvitals-0.1.0.dist-info/METADATA +134 -0
  461. stackvitals-0.1.0.dist-info/RECORD +465 -0
  462. stackvitals-0.1.0.dist-info/WHEEL +4 -0
  463. stackvitals-0.1.0.dist-info/entry_points.txt +3 -0
  464. stackvitals-0.1.0.dist-info/licenses/LICENSE +202 -0
  465. stackvitals-0.1.0.dist-info/licenses/NOTICE +16 -0
@@ -0,0 +1,232 @@
1
+ """Vulnerability history per package from OSV (free, no key): every advisory ever, merged across aliases (GHSA, PYSEC,
2
+ CVE are often the same flaw), with the ones still unfixed and any malware record. Records are cached by their modified
3
+ time, so a daily run fetches only new or changed ones."""
4
+ import re
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from datetime import datetime, timedelta
7
+ from urllib.parse import quote
8
+
9
+ from mergescout.signals.bumps import _norm
10
+ from mergescout.signals.deps_map import http_get_json, http_post_json
11
+
12
+ API = "https://api.osv.dev"
13
+ ECOSYSTEMS = {"pypi": "PyPI", "npm": "npm"}
14
+ QUERY_BATCH = 1000
15
+ # A first run would fetch every advisory at once (about 2,300 for 300 packages took 3 minutes); later runs only new ones.
16
+ MAX_FETCH = 4000
17
+ RANK = {"CRITICAL": 4, "HIGH": 3, "MODERATE": 2, "MEDIUM": 2, "LOW": 1}
18
+
19
+
20
+ def _ids(packages: list[tuple[str, str]], post_json) -> dict[str, dict[str, str]]:
21
+ # Advisory ids and their modified time per package; a package with many advisories answers in pages.
22
+ out = {}
23
+ wanted = [(eco, name) for eco, name in packages if eco in ECOSYSTEMS]
24
+ for i in range(0, len(wanted), QUERY_BATCH):
25
+ chunk = wanted[i:i + QUERY_BATCH]
26
+ queries = [{"package": {"name": name, "ecosystem": ECOSYSTEMS[eco]}} for eco, name in chunk]
27
+ results = post_json(f"{API}/v1/querybatch", {"queries": queries}).get("results") or []
28
+ for (eco, name), query, result in zip(chunk, queries, results):
29
+ found = {v["id"]: v.get("modified") for v in (result or {}).get("vulns") or []}
30
+ token = (result or {}).get("next_page_token")
31
+ while token:
32
+ more = (post_json(f"{API}/v1/querybatch", {"queries": [{**query, "page_token": token}]}).get("results") or [{}])[0]
33
+ found.update({v["id"]: v.get("modified") for v in (more or {}).get("vulns") or []})
34
+ token = (more or {}).get("next_page_token")
35
+ out[f"{eco}:{name}"] = found
36
+ return out
37
+
38
+
39
+ def _facts(record: dict, keys: set[str]) -> dict:
40
+ # The few fields the history needs; the advisory text is not kept.
41
+ pkgs, bounds, bad, spans = {}, {}, {}, {}
42
+ mal = _is_malware(record)
43
+ for a in record.get("affected") or []:
44
+ pkg = a.get("package") or {}
45
+ eco = next((k for k, v in ECOSYSTEMS.items() if v == pkg.get("ecosystem")), None)
46
+ key = f"{eco}:{_norm(eco, pkg.get('name') or '')}" if eco else None
47
+ if key in keys:
48
+ events = [e for r in a.get("ranges") or [] for e in r.get("events") or []]
49
+ pkgs[key] = pkgs.get(key, False) or any("fixed" in e for e in events)
50
+ # "last_affected" bounds the range without naming a fix: newer releases are not affected.
51
+ bounds[key] = bounds.get(key, []) + [e["last_affected"] for e in events if e.get("last_affected")]
52
+ # Each [introduced, fixed) span, so a PR's version can be checked and the fixing version named.
53
+ for r in a.get("ranges") or []:
54
+ if r.get("type") in ("ECOSYSTEM", "SEMVER"):
55
+ start = None
56
+ for e in r.get("events") or []:
57
+ if "introduced" in e:
58
+ start = e["introduced"]
59
+ elif "fixed" in e and start is not None:
60
+ spans.setdefault(key, []).append([start, e["fixed"]])
61
+ start = None
62
+ elif "last_affected" in e and start is not None:
63
+ # Up to and including that version, with no fix named.
64
+ spans.setdefault(key, []).append([start, e["last_affected"], True])
65
+ start = None
66
+ if start is not None:
67
+ spans.setdefault(key, []).append([start, None])
68
+ if mal:
69
+ # A malware record lists the bad versions; a range open from "0" means every version (OSV's rule).
70
+ every = any(e.get("introduced") == "0" for e in events) and not any("fixed" in e or "last_affected" in e
71
+ for e in events)
72
+ bad[key] = ["all"] if every or "all" in bad.get(key, []) else \
73
+ sorted(set(bad.get(key, [])) | set(a.get("versions") or []), key=_version_key)[-50:]
74
+ # OSV keeps CVEs the supplier disputes and says so in the text; only the flag is kept.
75
+ text = f"{record.get('summary') or ''} {record.get('details') or ''}".lower()
76
+ return {"rv": RECORD, "id": record.get("id"), "aliases": record.get("aliases") or [], "published": record.get("published"),
77
+ "modified": record.get("modified"), "withdrawn": bool(record.get("withdrawn")),
78
+ "severity": ((record.get("database_specific") or {}).get("severity") or "").upper() or None, "pkgs": pkgs,
79
+ "bounds": bounds, "ranges": spans, "disputed": "disputed" in text, **({"bad": bad} if mal else {})}
80
+
81
+
82
+ def _is_malware(record: dict) -> bool:
83
+ return any(n.startswith("MAL-") for n in (record.get("id") or "", *(record.get("aliases") or [])))
84
+
85
+
86
+ # The record format: a record built by older code is read again (last_affected spans came in version 2).
87
+ RECORD = 2
88
+ # Pre-releases sort before their release and post-releases after it (PEP 440 and semver agree on that much).
89
+ PHASES = {"dev": 0, "a": 1, "alpha": 1, "b": 2, "beta": 2, "c": 3, "rc": 3, "pre": 3, "preview": 3, "post": 5}
90
+
91
+
92
+ def version_key(v: str | None) -> tuple:
93
+ """(epoch, release padded to 4, phase, number): 2.0.0rc1 < 2.0.0 < 2.0.0.post1, 1!0.1 > 9.9."""
94
+ s = (v or "").strip().lower().removeprefix("v")
95
+ epoch = 0
96
+ if "!" in s:
97
+ head, s = s.split("!", 1)
98
+ epoch = int(head) if head.isdigit() else 0
99
+ m = re.match(r"(\d+(?:\.\d+)*)(.*)", s)
100
+ if not m:
101
+ return (epoch, (0, 0, 0, 0), 4, 0)
102
+ release = tuple(int(x) for x in m.group(1).split(".")[:4])
103
+ release += (0,) * (4 - len(release))
104
+ rest = m.group(2).split("+", 1)[0]
105
+ tag = re.search(r"(dev|alpha|beta|preview|pre|post|rc|a|b|c)[.-]?(\d*)", rest)
106
+ if tag:
107
+ return (epoch, release, PHASES[tag.group(1)], int(tag.group(2) or 0))
108
+ # Any other suffix after a dash (1.0.0-next.3) is a semver pre-release.
109
+ return (epoch, release, 3 if rest.startswith("-") else 4, 0)
110
+
111
+
112
+ def _version_key(v: str) -> tuple:
113
+ return version_key(v)
114
+
115
+
116
+ def _cves(group: list[dict]) -> list[str]:
117
+ return sorted({n for x in group for n in (x["id"], *x["aliases"]) if n.startswith("CVE-")})
118
+
119
+
120
+ def _summary(key: str, facts: list[dict], now: datetime, exploits: dict | None = None) -> dict:
121
+ live = [f for f in facts if not f["withdrawn"] and key in f["pkgs"]]
122
+ # One flaw under several ids (aliases) counts once: group ids that share any id or alias.
123
+ groups, seen = [], {}
124
+ for f in live:
125
+ names = {f["id"], *f["aliases"]}
126
+ hit = {seen[n] for n in names if n in seen}
127
+ merged = [f]
128
+ for g in sorted(hit, reverse=True):
129
+ merged += groups.pop(g)
130
+ groups.append(merged)
131
+ seen = {n: i for i, g in enumerate(groups) for x in g for n in (x["id"], *x["aliases"])}
132
+ since = (now - timedelta(days=730)).strftime("%Y-%m-%d")
133
+ recent = (now - timedelta(days=90)).strftime("%Y-%m-%d")
134
+ severity, out = {}, {"advisories": len(groups), "last_2y": 0, "last_90d": 0, "unfixed": 0, "latest": None, "ids": []}
135
+ malware, last_affected, bad = set(), set(), set()
136
+ out["disputed"] = 0
137
+ kev, epss_open, epss_max = [], None, None
138
+ affects = []
139
+ open_label = None
140
+ for g in groups:
141
+ first = min((x["published"] or "")[:10] for x in g)
142
+ out["last_2y"] += first >= since
143
+ out["last_90d"] += first >= recent
144
+ # A malware record lists bad versions and never has a fix; it is reported as malware, not as unfixed.
145
+ mal = {n for x in g for n in (x["id"], *x["aliases"]) if n.startswith("MAL-")}
146
+ malware |= mal
147
+ if mal:
148
+ bad |= {v for x in g for v in (x.get("bad") or {}).get(key, [])}
149
+ fixed = any(x["pkgs"][key] for x in g)
150
+ bound = [v for x in g for v in (x.get("bounds") or {}).get(key, [])]
151
+ if mal or fixed:
152
+ pass
153
+ elif any(x.get("disputed") for x in g):
154
+ out["disputed"] += 1
155
+ elif bound:
156
+ # Open only while the newest release is inside the bound; the dossier compares with the current version.
157
+ last_affected.add(max(bound, key=_version_key))
158
+ else:
159
+ out["unfixed"] += 1
160
+ # Exploited in the wild (KEV), and the chance of it (EPSS): an open advisory's matters most.
161
+ for cve in _cves(g) if exploits is not None else ():
162
+ e = exploits.get(cve) or {}
163
+ if e.get("kev"):
164
+ kev.append({"id": cve, "fixed": bool(fixed)})
165
+ if "epss" in e:
166
+ score = {"id": cve, "score": e["epss"], "percentile": e.get("percentile")}
167
+ epss_max = max(epss_max or score, score, key=lambda s: s["score"])
168
+ if not (mal or fixed or bound):
169
+ epss_open = max(epss_open or score, score, key=lambda s: s["score"])
170
+ spans = [s for x in g for s in (x.get("ranges") or {}).get(key, [])]
171
+ label = max((x["severity"] for x in g if x["severity"]), key=lambda s: RANK.get(s, 0), default=None)
172
+ disputed = any(x.get("disputed") for x in g)
173
+ if spans and not mal:
174
+ affects.append({"id": g[0]["id"], "ranges": spans, "severity": label, "disputed": disputed})
175
+ out["latest"] = max(out["latest"] or "", first) or None
176
+ if label:
177
+ severity[label] = severity.get(label, 0) + 1
178
+ # The worst advisory still open (no fix, or a range ending at a bound), for a repo's severity policy.
179
+ if not (mal or fixed or bound or disputed) and RANK.get(label, 0) > RANK.get(open_label or "", 0):
180
+ open_label = label
181
+ out["severity"], out["open_severity"] = severity, open_label
182
+ out["affects"] = affects
183
+ out["malware"], out["malware_ids"] = bool(malware), sorted(malware)
184
+ out["malware_versions"] = ["all"] if "all" in bad else sorted(bad, key=_version_key)[-50:]
185
+ out["last_affected"] = sorted(last_affected, key=_version_key)
186
+ out["ids"] = [g[0]["id"] for g in sorted(groups, key=lambda g: min(x["published"] or "" for x in g), reverse=True)[:5]]
187
+ if exploits is not None:
188
+ out["kev"] = sorted({k["id"]: k for k in kev}.values(), key=lambda k: k["id"])
189
+ out["epss_open"], out["epss_max"] = epss_open, epss_max
190
+ return out
191
+
192
+
193
+ def _fetch(get_json, advisory_id: str) -> dict | None:
194
+ # A record lost after retries is left out of the cache, so the next run asks for it again.
195
+ try:
196
+ return get_json(f"{API}/v1/vulns/{quote(advisory_id, safe='')}")
197
+ except Exception: # noqa: BLE001
198
+ return None
199
+
200
+
201
+ def refresh_vulns(packages: list[tuple[str, str]], previous: dict, now: datetime, post_json=http_post_json,
202
+ get_json=http_get_json, workers: int = 8, max_fetch: int = MAX_FETCH, exploits=None) -> dict:
203
+ """{"records": cached facts by advisory id, "packages": history per "ecosystem:name"}; only new or changed
204
+ advisories are fetched again. exploits: a lookup cves -> {cve: {"epss", "percentile", "kev"}} (signals.exploits);
205
+ without one, no exploit evidence is added."""
206
+ keys = {f"{eco}:{name}" for eco, name in packages if eco in ECOSYSTEMS}
207
+ ids = _ids(packages, post_json)
208
+ old = previous.get("records") or {}
209
+ # Records cached before ranges' bounds (or malware versions) were kept are read once more.
210
+ stale = lambda r: r.get("rv") != RECORD or (_is_malware(r) and "bad" not in r) # noqa: E731
211
+ todo = sorted({i for found in ids.values() for i, mod in found.items()
212
+ if (old.get(i) or {}).get("modified") != mod or stale(old.get(i) or {})})
213
+ complete = len(todo) <= max_fetch
214
+ todo = todo[:max_fetch]
215
+ with ThreadPoolExecutor(workers) as pool:
216
+ fetched = list(pool.map(lambda i: _fetch(get_json, i), todo))
217
+ records = {i: old[i] for found in ids.values() for i in found if i in old and i not in todo}
218
+ # The cache keeps the batch answer's modified time: the record itself may spell it with other precision.
219
+ modified = {i: mod for found in ids.values() for i, mod in found.items()}
220
+ records.update({i: {**_facts(r, keys), "modified": modified[i]} for i, r in zip(todo, fetched) if r})
221
+ found = None
222
+ if exploits is not None:
223
+ cves = sorted({n for r in records.values() for n in (r["id"], *r["aliases"]) if n.startswith("CVE-")})
224
+ found = exploits(cves) or {}
225
+ packages_out = {key: _summary(key, [records[i] for i in ids.get(key, {}) if i in records], now, found)
226
+ for key in sorted(keys)}
227
+ # Not complete: some advisories wait for the next run, so counts may still grow.
228
+ out = {"built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "complete": complete, "records": records, "packages": packages_out}
229
+ if found is not None:
230
+ out["exploits"] = {"cves": len(cves), "scored": sum("epss" in e for e in found.values()),
231
+ "kev": sum(bool(e.get("kev")) for e in found.values())}
232
+ return out
@@ -0,0 +1,80 @@
1
+ """Who can publish each package, watched daily: npm maintainers and the publisher of the latest release (and whether it came
2
+ through trusted CI publishing), PyPI owners and organization. Registries keep no history of this; the changes are the
3
+ signal (a takeover usually starts with a new maintainer or a release published outside the usual CI). Emails are never kept."""
4
+ import time
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from datetime import datetime
7
+ from urllib.parse import quote
8
+
9
+ from mergescout.signals.deps_map import http_get_json
10
+
11
+ MAX_EVENTS = 20
12
+
13
+
14
+ def _snapshot(eco: str, name: str, version: str | None, get_json) -> dict | None:
15
+ if eco == "npm":
16
+ d = get_json(f"https://registry.npmjs.org/{quote(name, safe='@')}/latest")
17
+ if not d:
18
+ return None
19
+ user = d.get("_npmUser") or {}
20
+ trusted = bool(user.get("trustedPublisher") or (d.get("dist") or {}).get("attestations"))
21
+ return {"version": d.get("version"), "publisher": user.get("name"), "trusted": trusted,
22
+ "maintainers": sorted(m.get("name") for m in d.get("maintainers") or [] if m.get("name"))}
23
+ if eco == "pypi" and version:
24
+ d = get_json(f"https://pypi.org/pypi/{quote(name, safe='')}/{quote(version, safe='')}/json")
25
+ if not d:
26
+ return None
27
+ own = d.get("ownership") or {}
28
+ return {"version": version, "organization": own.get("organization"),
29
+ "maintainers": sorted(r.get("user") for r in own.get("roles") or [] if r.get("user"))}
30
+ return None
31
+
32
+
33
+ def _events(old: dict, new: dict, day: str) -> list[dict]:
34
+ out = []
35
+ added = sorted(set(new.get("maintainers") or []) - set(old.get("maintainers") or []))
36
+ removed = sorted(set(old.get("maintainers") or []) - set(new.get("maintainers") or []))
37
+ if added:
38
+ out.append({"kind": "maintainer_added", "date": day, "who": added})
39
+ if removed:
40
+ out.append({"kind": "maintainer_removed", "date": day, "who": removed})
41
+ if old.get("trusted") and new.get("trusted") is False:
42
+ out.append({"kind": "trusted_lost", "date": day, "version": new.get("version")})
43
+ if old.get("publisher") and new.get("publisher") and old["publisher"] != new["publisher"]:
44
+ out.append({"kind": "publisher_changed", "date": day, "from": old["publisher"], "to": new["publisher"]})
45
+ if old.get("organization") != new.get("organization") and ("organization" in old or "organization" in new):
46
+ out.append({"kind": "organization_changed", "date": day, "from": old.get("organization"), "to": new.get("organization")})
47
+ return out
48
+
49
+
50
+ def refresh_owners(packages: list[tuple[str, str, str | None]], previous: dict, now: datetime, get_json=http_get_json,
51
+ workers: int = 16, budget_s: float = 900) -> dict:
52
+ """Today's snapshot per package and the dated changes since watching began; a package not read today (an error or
53
+ the time budget) keeps yesterday's entry and gets no event."""
54
+ day = now.strftime("%Y-%m-%d")
55
+ deadline = time.time() + budget_s
56
+ old = previous.get("packages") or {}
57
+
58
+ def read(item):
59
+ eco, name, version = item
60
+ if time.time() > deadline:
61
+ return None
62
+ try:
63
+ return _snapshot(eco, name, version, get_json)
64
+ except Exception: # noqa: BLE001
65
+ return None
66
+ with ThreadPoolExecutor(workers) as pool:
67
+ snaps = list(pool.map(read, packages))
68
+ out = {}
69
+ for (eco, name, _), snap in zip(packages, snaps):
70
+ key, before = f"{eco}:{name}", old.get(f"{eco}:{name}")
71
+ if snap is None:
72
+ if before:
73
+ out[key] = before
74
+ continue
75
+ if before:
76
+ events = (before.get("events") or []) + _events(before.get("current") or {}, snap, day)
77
+ out[key] = {"current": snap, "since": before.get("since", day), "events": events[-MAX_EVENTS:]}
78
+ else:
79
+ out[key] = {"current": snap, "since": day, "events": []}
80
+ return {"built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "packages": out}
@@ -0,0 +1,96 @@
1
+ import math
2
+ from datetime import datetime, timedelta
3
+
4
+ import duckdb
5
+
6
+ from mergescout.signals.rules import RULES, Rules
7
+
8
+
9
+ def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float] | None:
10
+ if n == 0:
11
+ return None
12
+ p = k / n
13
+ centre = p + z * z / (2 * n)
14
+ margin = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
15
+ denom = 1 + z * z / n
16
+ return (centre - margin) / denom, min(1.0, (centre + margin) / denom)
17
+
18
+
19
+ def _wilson_macros(con: duckdb.DuckDBPyConnection) -> None:
20
+ con.execute("""
21
+ CREATE OR REPLACE MACRO wilson_lo(k, n) AS CASE WHEN n = 0 THEN NULL ELSE
22
+ ((k::DOUBLE / n + 1.9208 / n) - 1.96 * sqrt((k::DOUBLE / n) * (1 - k::DOUBLE / n) / n + 0.9604 / (n * n)))
23
+ / (1 + 3.8416 / n) END;
24
+ CREATE OR REPLACE MACRO wilson_hi(k, n) AS CASE WHEN n = 0 THEN NULL ELSE least(1.0,
25
+ ((k::DOUBLE / n + 1.9208 / n) + 1.96 * sqrt((k::DOUBLE / n) * (1 - k::DOUBLE / n) / n + 0.9604 / (n * n)))
26
+ / (1 + 3.8416 / n)) END;""")
27
+
28
+
29
+ def build_repo_stats(con: duckdb.DuckDBPyConnection, now: datetime, rules: Rules = RULES) -> None:
30
+ _wilson_macros(con)
31
+ since = now - timedelta(days=rules.window_days)
32
+ con.execute("""
33
+ CREATE OR REPLACE TABLE repo_stats AS
34
+ WITH ranked AS (
35
+ SELECT repo_id, created_at, row_number() OVER (PARTITION BY repo_id ORDER BY created_at DESC) AS rn
36
+ FROM prs WHERE created_at >= $since),
37
+ -- Full crawls keep the newest PRs up to the cap; refreshes can add older ones, which must not widen the window.
38
+ span AS (
39
+ SELECT p.repo_id, coalesce(c.seen, 0) >= $cap AS truncated,
40
+ CASE WHEN coalesce(c.seen, 0) >= $cap THEN c.cap_start ELSE $since END AS window_start
41
+ FROM (SELECT DISTINCT repo_id FROM prs) p
42
+ LEFT JOIN (SELECT repo_id, count(*) AS seen, min(created_at) FILTER (WHERE rn <= $cap) AS cap_start
43
+ FROM ranked GROUP BY repo_id) c USING (repo_id)),
44
+ -- Prefer the first person's review; older batches only know the very first review.
45
+ w AS (SELECT * REPLACE (coalesce(first_human_review_at, first_review_at) AS first_review_at,
46
+ coalesce(first_human_review_by, first_review_by) AS first_review_by)
47
+ FROM pr_class JOIN span USING (repo_id) WHERE created_at >= window_start),
48
+ firsts AS (
49
+ SELECT repo_id, author_login, min(merged_at) AS first_merge FROM pr_class
50
+ WHERE author_class = 'outsider' AND merged_at IS NOT NULL GROUP BY ALL),
51
+ o AS (
52
+ SELECT w.repo_id,
53
+ count(*) AS outsider_prs,
54
+ count(*) FILTER (WHERE state = 'MERGED') AS merged,
55
+ count(*) FILTER (WHERE state = 'CLOSED') AS closed,
56
+ count(*) FILTER (WHERE state = 'OPEN') AS open,
57
+ count(*) FILTER (WHERE state IN ('MERGED', 'CLOSED')
58
+ AND (f.first_merge IS NULL OR w.created_at <= f.first_merge)) AS newcomer_decided,
59
+ count(*) FILTER (WHERE w.merged_at IS NOT NULL AND w.merged_at = f.first_merge) AS newcomer_merged,
60
+ count(*) FILTER (WHERE first_review_at IS NOT NULL
61
+ AND first_review_by IS DISTINCT FROM w.author_login AND rv.kind = 'insider') AS reviewed,
62
+ median(epoch(first_review_at - created_at) / 3600) FILTER (
63
+ WHERE first_review_at IS NOT NULL AND first_review_by IS DISTINCT FROM w.author_login
64
+ AND rv.kind = 'insider')
65
+ AS median_review_hours
66
+ FROM w LEFT JOIN firsts f USING (repo_id, author_login)
67
+ -- Only a maintainer's review counts as a response; review bots answer within minutes.
68
+ LEFT JOIN people rv ON rv.repo_id = w.repo_id AND rv.login = w.first_review_by
69
+ WHERE w.author_class = 'outsider' GROUP BY w.repo_id),
70
+ mix AS (
71
+ SELECT repo_id, count(*) AS window_prs,
72
+ count(*) FILTER (WHERE author_class = 'ai') AS ai_prs,
73
+ count(*) FILTER (WHERE author_class = 'bot') AS bot_prs,
74
+ count(*) FILTER (WHERE author_class = 'insider') AS insider_prs
75
+ FROM w GROUP BY repo_id)
76
+ SELECT span.repo_id, rm.name, rm.stars,
77
+ -- When the PR cap cut history short, say how many days we really cover.
78
+ CASE WHEN span.truncated THEN date_diff('day', span.window_start, $now) ELSE $window END AS window_days,
79
+ NOT span.truncated AS window_complete,
80
+ coalesce(o.outsider_prs, 0) AS outsider_prs, coalesce(o.merged, 0) AS merged,
81
+ coalesce(o.closed, 0) AS closed, coalesce(o.open, 0) AS open,
82
+ o.merged::DOUBLE / nullif(o.merged + o.closed, 0) AS merge_rate,
83
+ wilson_lo(o.merged, o.merged + o.closed) AS merge_low, wilson_hi(o.merged, o.merged + o.closed) AS merge_high,
84
+ coalesce(o.merged + o.closed, 0) >= $min_decided AS merge_enough,
85
+ coalesce(o.newcomer_decided, 0) AS newcomer_decided, coalesce(o.newcomer_merged, 0) AS newcomer_merged,
86
+ o.newcomer_merged::DOUBLE / nullif(o.newcomer_decided, 0) AS newcomer_rate,
87
+ wilson_lo(o.newcomer_merged, o.newcomer_decided) AS newcomer_low,
88
+ wilson_hi(o.newcomer_merged, o.newcomer_decided) AS newcomer_high,
89
+ coalesce(o.newcomer_decided, 0) >= $min_decided AS newcomer_enough,
90
+ coalesce(o.reviewed, 0) AS reviewed, o.median_review_hours,
91
+ coalesce(o.reviewed, 0) >= $min_reviewed AS review_enough,
92
+ coalesce(mix.window_prs, 0) AS window_prs, coalesce(mix.ai_prs, 0) AS ai_prs,
93
+ coalesce(mix.bot_prs, 0) AS bot_prs, coalesce(mix.insider_prs, 0) AS insider_prs
94
+ FROM span LEFT JOIN o USING (repo_id) LEFT JOIN mix USING (repo_id) LEFT JOIN repo_meta rm USING (repo_id)""",
95
+ {"since": since, "now": now, "cap": rules.pr_cap, "window": rules.window_days,
96
+ "min_decided": rules.min_decided, "min_reviewed": rules.min_reviewed})
@@ -0,0 +1,70 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class Rules:
6
+ window_days: int = 365
7
+ min_decided: int = 20
8
+ min_closed: int = 10
9
+ min_reviewed: int = 5
10
+ claim_days: int = 14
11
+ interest_days: int = 30
12
+ stale_days: int = 180
13
+ pr_cap: int = 300
14
+
15
+
16
+ RULES = Rules()
17
+
18
+ # Accounts that act like bots but are typed as users; extend when the hand-check finds more.
19
+ KNOWN_BOTS = frozenset({
20
+ "miss-islington", "bors", "homu", "k8s-ci-robot", "k8s-triage-robot", "k8s-merge-robot", "pytorchbot",
21
+ "facebook-github-bot", "googlebot", "google-cla", "copybara-service", "tensorflow-jenkins", "rustbot",
22
+ "rust-highfive", "rust-timer", "nodejs-github-bot", "chromium-wpt-export-bot", "mergify", "kodiakhq",
23
+ "allcontributors", "stale", "netlify", "vercel", "coveralls", "codecov-commenter", "sonarcloud",
24
+ "openshift-ci-robot", "openshift-merge-robot", "ti-chi-bot", "ti-srebot", "elasticmachine", "apache-bot",
25
+ "copilot-pull-request-reviewer", "coderabbitai", "gemini-code-assist", "sourcery-ai", "greptile-apps",
26
+ "home-assistant", "welcome", "linear", "similar-issues-ai", "oss-sentinel-ai", "hfla-graphql-app",
27
+ # CI accounts that open failing-test issues (seen 2026-10-16).
28
+ "kibanamachine", "elasticsearchmachine", "brave-builds", "gordontheturtle",
29
+ })
30
+ # Comments carry no account type, so bots that only comment are known by their names (seen on 2,545 repos).
31
+ BOT_NAME_PATTERN = r"(bot|-ci|github-actions|-automation|-integration)$"
32
+ UNKNOWN_LOGINS = frozenset({"ghost"})
33
+ # Accounts that are AI coding agents, not ordinary automation; their PRs count as AI-written.
34
+ AI_AGENT_LOGINS = frozenset({"copilot-swe-agent", "devin-ai-integration", "chatgpt-codex-connector", "cursor-agent",
35
+ "sweep-ai", "openhands-agent", "openhands-ai"})
36
+ # Plain words a person can also use as a login: AI only when the account is a bot.
37
+ AI_AGENT_BOT_NAMES = frozenset({"copilot", "claude", "codex", "cursor", "devin"})
38
+
39
+ # Real comments also say "...speaker and would like to work on this" and "I'll open a PR" (symfony#66111).
40
+ CLAIM_PATTERN = (r"(i('d| would) (like|love) to (work on|take|tackle|pick)"
41
+ r"|\band would (like|love) to (work on|take|tackle|pick up) (this|it)\b"
42
+ r"|(can|could|may) i (work on|take|pick|tackle|try)"
43
+ r"|assign (this|it) to me|please assign|^/assign|i('m| am) (working|taking) (on )?(this|it)"
44
+ r"|let me (take|work on|try|pick)|i('ll| will) (take|work on|pick up|try) (this|it)"
45
+ r"|i('ll| will) (open|submit|send|raise|create) (a )?(pr|pull request)\b)")
46
+ WAIT_PATTERN = (r"(please (wait|hold)|on hold|blocked (by|on)|needs? (design|discussion|decision|triage)"
47
+ r"|not (ready|accepting)|we('re| are) (still )?(discussing|deciding)|do not (open|send) (a )?(pr|pull))")
48
+ # Surnames that end in "bot" (seen: adamrtalbot); RE2 has no lookbehind, so they are excluded separately.
49
+ HUMAN_BOT_NAMES = r"(talbot|abbot|cabot|sabot)$"
50
+ # Labels (after norm_label) that say the issue is not ready for a PR, e.g. "status: blocked", "needs info".
51
+ # Anchored: real labels "triage accepted", "never stale", "question bank" and "triage needs pr" mean ready or a topic.
52
+ PREFIX = r"^((status|state|type|kind|resolution|issue|s|t) )?"
53
+ WAITING = r"(design|discussion|decision|info|information|feedback|repro|reproduction|investigation|confirmation)"
54
+ # Also labels saying someone is on it ("in progress", "claimed", "gssoc assigned") or it is done ("fixed").
55
+ WAIT_LABEL_PATTERN = (rf"{PREFIX}(blocked|on hold|wontfix|won t fix|invalid|duplicate|not planned|"
56
+ rf"maybe later|(\d+ )?in progress|work in progress|wip|claimed( \w+)?|"
57
+ rf"taken|has pr|pr open|pr submitted|fixed|resolved( pending close)?|(wait|waiting) for review)$"
58
+ rf"|\bneeds? (more )?{WAITING}\b|\b(awaiting|waiting for|waiting on|pending|requires) "
59
+ rf"({WAITING}|response|reply)\b|\btriage needs {WAITING}\b"
60
+ r"|^(gssoc|gsoc|ssoc|hacktoberfest|kwoc|swoc|jwoc|iwoc|dwoc|osci)\w* assigned$")
61
+ # "Not settled yet" labels (triage, question, proposal). Repos add them to every new issue or keep them after a
62
+ # decision (golang: "Proposal" + "Proposal-Accepted"), so a beginner or help label added later outweighs them.
63
+ TRIAGE_LABEL_PATTERN = (rf"{PREFIX}(triage|untriaged|unconfirmed|question|discussion|in discussion|rfc|proposal)$"
64
+ r"|\bneeds? triage\b|\b(awaiting|waiting for|pending|requires) triage\b"
65
+ r"|\btriage (needed|pending|required)\b")
66
+ # Titles like "[RFC] ..." or "Epic: ..." and labels like "epic" mark trackers and debates, not tasks.
67
+ KINDS = r"(epic|rfc|discussion|tracking|tracker|meta|roadmap|proposal|umbrella|poll|survey)"
68
+ DISCUSSION_TITLE = rf"^\s*[\[(]\s*{KINDS}\s*[\])]|^\s*{KINDS}\s*:|\btracking issue\b|^\s*dependency dashboard\s*$"
69
+ LABEL_KINDS = r"(epic|tracking|tracker|roadmap|umbrella|poll|survey)"
70
+ DISCUSSION_LABEL = rf"^((type|kind) )?{LABEL_KINDS}$"
@@ -0,0 +1,33 @@
1
+ from pathlib import Path
2
+
3
+ import duckdb
4
+ import pyarrow as pa
5
+
6
+ from mergescout.crawl.writer import SCHEMAS
7
+
8
+ # One row per key survives: the most recently fetched copy.
9
+ KEYS = {"repo_meta": ["repo_id"], "prs": ["repo_id", "number"], "issues": ["repo_id", "number"],
10
+ "comments": ["repo_id", "number", "comment_id", "created_at"], "closed_issues": ["repo_id", "number"],
11
+ "issue_events": ["repo_id", "number", "event", "observed_at"]}
12
+
13
+
14
+ def load_silver(con: duckdb.DuckDBPyConnection, roots: list[Path]) -> dict[str, int]:
15
+ con.execute("SET TimeZone = 'UTC'") # day counts must not depend on the machine's clock zone
16
+ counts = {}
17
+ for table, key in KEYS.items():
18
+ files = sorted(str(p) for root in roots for p in (root / "crawl" / table).glob("*.parquet"))
19
+ con.register(f"{table}_empty", pa.Table.from_pylist([], schema=SCHEMAS[table]))
20
+ source = f"SELECT * FROM {table}_empty"
21
+ if files:
22
+ con.read_parquet(files, union_by_name=True).create_view(f"{table}_raw", replace=True)
23
+ # BY NAME fills columns that older batches do not have with nulls.
24
+ source += f" UNION ALL BY NAME SELECT * FROM {table}_raw"
25
+ con.execute(f"""
26
+ CREATE OR REPLACE TABLE {table} AS
27
+ SELECT * EXCLUDE (rn) FROM (
28
+ SELECT *, row_number() OVER (PARTITION BY {", ".join(key)}
29
+ ORDER BY fetched_at DESC NULLS LAST, run_id DESC) AS rn
30
+ FROM ({source})) WHERE rn = 1""")
31
+ # fetchall, not fetchone: DuckDB 2 refuses any other work while a result is half read.
32
+ counts[table] = con.execute(f"SELECT count(*) FROM {table}").fetchall()[0][0]
33
+ return counts
@@ -0,0 +1,26 @@
1
+ """The most-used npm and PyPI packages (by repos depending on them, from ecosyste.ms), so the evidence covers what people
2
+ add, not only what crawled repos happen to upgrade."""
3
+ from mergescout.signals.bump_ledger import valid_name
4
+ from mergescout.signals.bumps import _norm
5
+ from mergescout.signals.deps_map import http_get_json
6
+
7
+ REGISTRIES = {"npm": "npmjs.org", "pypi": "pypi.org"}
8
+ URL = ("https://packages.ecosyste.ms/api/v1/registries/{registry}/packages"
9
+ "?sort=dependent_repos_count&order=desc&per_page=100&page={page}")
10
+
11
+
12
+ def fetch_top(n: int = 1000, get_json=None) -> list[tuple[str, str]]:
13
+ """The top n of each registry, most-used first; a page that fails ends that registry's list for today."""
14
+ get_json = get_json or http_get_json
15
+ out = []
16
+ for eco, registry in REGISTRIES.items():
17
+ for page in range(1, -(-n // 100) + 1):
18
+ try:
19
+ rows = get_json(URL.format(registry=registry, page=page))
20
+ except Exception: # noqa: BLE001
21
+ break
22
+ if not isinstance(rows, list) or not rows:
23
+ break
24
+ out += [(eco, _norm(eco, r["name"])) for r in rows
25
+ if isinstance(r, dict) and isinstance(r.get("name"), str) and valid_name(eco, r["name"])]
26
+ return list(dict.fromkeys(out))
@@ -0,0 +1,71 @@
1
+ import hashlib
2
+ from datetime import datetime, timedelta
3
+
4
+ import duckdb
5
+
6
+ from mergescout.signals.triage_math import buckets, calibration, fit
7
+
8
+ DEFINITION = ("outside contributors' PRs opened 30 to 395 days ago; outcome = merged within 30 days of opening; "
9
+ "every feature is as of the PR's opening: the author's earlier PRs and merges here, and the repo's rate over "
10
+ "outside PRs opened 30 to 395 days before it. "
11
+ "Checked on one repo in five, never seen in training.")
12
+ MIN_REPO_PRS = 10
13
+
14
+
15
+ def training_rows(con: duckdb.DuckDBPyConnection, now: datetime) -> list[dict]:
16
+ """One row per outside PR whose 30-day outcome is known, with only what was known when it was opened."""
17
+ rows = con.execute("""
18
+ WITH outs AS (
19
+ SELECT repo_id, number, created_at, author_login, additions, deletions, closing_issue_numbers,
20
+ (merged_at IS NOT NULL AND merged_at <= created_at + INTERVAL 30 DAY) AS merged30
21
+ FROM pr_class WHERE author_class = 'outsider' AND author_login IS NOT NULL),
22
+ -- The repo's rate as of each PR: outside PRs opened 30-395 days earlier, whose 30-day outcome was known by then.
23
+ rated AS (
24
+ SELECT o.*, count(*) OVER w AS past_n, sum(merged30::INT) OVER w AS past_k FROM outs o
25
+ WINDOW w AS (PARTITION BY repo_id ORDER BY created_at
26
+ RANGE BETWEEN INTERVAL 395 DAY PRECEDING AND INTERVAL 30 DAY PRECEDING)),
27
+ base AS (SELECT * FROM rated WHERE created_at >= $lo AND created_at <= $hi),
28
+ merges AS (SELECT repo_id, author_login, merged_at FROM pr_class WHERE merged_at IS NOT NULL AND author_login IS NOT NULL),
29
+ -- Running totals read "as of" each PR's opening: cheap even for prolific authors.
30
+ here AS (SELECT repo_id, author_login, merged_at,
31
+ row_number() OVER (PARTITION BY repo_id, author_login ORDER BY merged_at) AS done FROM merges),
32
+ opened AS (SELECT repo_id, author_login, created_at,
33
+ row_number() OVER (PARTITION BY repo_id, author_login ORDER BY created_at) AS done
34
+ FROM pr_class WHERE author_login IS NOT NULL),
35
+ counted AS (
36
+ SELECT b.*, coalesce(h.done, 0) AS here_merged, coalesce(o.done, 0) AS here_opened
37
+ FROM base b
38
+ ASOF LEFT JOIN here h ON h.repo_id = b.repo_id AND h.author_login = b.author_login AND b.created_at > h.merged_at
39
+ ASOF LEFT JOIN opened o ON o.repo_id = b.repo_id AND o.author_login = b.author_login AND b.created_at > o.created_at)
40
+ SELECT repo_id, number, here_merged,
41
+ coalesce(additions, 0) + coalesce(deletions, 0) AS lines,
42
+ coalesce(len(closing_issue_numbers), 0) > 0 AS linked_issue,
43
+ -- GitHub reports the author's association as of the query, which leaks the outcome; history does not.
44
+ here_opened = 0 AS first_timer,
45
+ CASE WHEN past_n >= $min_repo THEN past_k::DOUBLE / past_n END AS repo_rate,
46
+ merged30 AS merged
47
+ FROM counted""",
48
+ {"lo": now - timedelta(days=395), "hi": now - timedelta(days=30), "min_repo": MIN_REPO_PRS}).fetchall()
49
+ cols = ("repo", "number", "here_merged", "lines", "linked_issue", "first_timer", "repo_rate", "merged")
50
+ return [dict(zip(cols, r)) for r in rows]
51
+
52
+
53
+ def _held_out(repo_id: int) -> bool:
54
+ # One repo in five, picked by a real hash so neighbouring ids don't land together.
55
+ return int(hashlib.sha1(str(repo_id).encode()).hexdigest(), 16) % 5 == 0
56
+
57
+
58
+ def build_triage_model(con: duckdb.DuckDBPyConnection, now: datetime) -> dict:
59
+ """Fit on four repos in five, check calibration on the fifth, then publish a model fitted on all of them."""
60
+ rows = []
61
+ for r in training_rows(con, now):
62
+ feats = buckets(r["here_merged"], r["lines"], r["linked_issue"], r["first_timer"], r["repo_rate"])
63
+ rows.append({**feats, "merged": bool(r["merged"]), "repo": r["repo"]})
64
+ train = [r for r in rows if not _held_out(r["repo"])]
65
+ test = [r for r in rows if _held_out(r["repo"])]
66
+ evaluation = {"train_prs": len(train), "test_prs": len(test)}
67
+ if train and test:
68
+ evaluation |= calibration(fit(train), test)
69
+ model = fit(rows) if rows else fit([])
70
+ return {**model, "built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "definition": DEFINITION, "prs": len(rows),
71
+ "evaluation": evaluation}