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,96 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ import tarfile
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ import duckdb
9
+
10
+ from mergescout.config import load_config
11
+ from mergescout.github.releases import Releases
12
+ from mergescout.signals.evidence import TABLES, build_all
13
+ from mergescout.signals.silver import load_silver
14
+ from mergescout.store.sync import fetch_bronze
15
+
16
+
17
+ def main(argv: list[str] | None = None) -> int:
18
+ parser = argparse.ArgumentParser(prog="python -m mergescout.signals", description="Build Tier 1 signals.")
19
+ sub = parser.add_subparsers(dest="cmd", required=True)
20
+ b = sub.add_parser("build", help="Read all crawl batches and write signal tables as Parquet")
21
+ b.add_argument("--from-releases", action="store_true", help="Download crawl bundles from releases first")
22
+ b.add_argument("--out", default=None)
23
+ c = sub.add_parser("check", help="Hand-check identity on 30 repos against owner files and public org members")
24
+ c.add_argument("--signals", required=True)
25
+ c.add_argument("--out", required=True)
26
+ o = sub.add_parser("opendigger", help="Compare review times with OpenDigger")
27
+ o.add_argument("--signals", required=True)
28
+ o.add_argument("--repos", type=int, default=100)
29
+ args = parser.parse_args(argv)
30
+
31
+ if args.cmd in ("check", "opendigger"):
32
+ return _validate(args)
33
+
34
+ cfg = load_config()
35
+ out = Path(args.out) if args.out else cfg.data_dir / "signals"
36
+ con = prepare(cfg, out, args.from_releases, datetime.now(timezone.utc))
37
+ if con is None:
38
+ return 1
39
+ write_bundle(con, out)
40
+ con.close()
41
+ return 0
42
+
43
+
44
+ def prepare(cfg, out: Path, from_releases: bool, now: datetime) -> duckdb.DuckDBPyConnection | None:
45
+ # Silver + every signal table; None when there is no crawl data to build from.
46
+ out.mkdir(parents=True, exist_ok=True)
47
+ if from_releases:
48
+ # Same store as the crawler's `store` command: MS_STORE_REPO, else the running repo.
49
+ token = os.environ.get("MS_RELEASE_TOKEN")
50
+ repo = os.environ.get("MS_STORE_REPO") or os.environ.get("GITHUB_REPOSITORY")
51
+ if not token or not repo:
52
+ print("Set MS_RELEASE_TOKEN and MS_STORE_REPO (or GITHUB_REPOSITORY) to read crawl releases.",
53
+ file=sys.stderr)
54
+ return None
55
+ fetch_bronze(Releases(token, repo), cfg.data_dir, log=lambda msg: print(msg, flush=True))
56
+ # A file database lets DuckDB spill to disk instead of running out of memory on the runner.
57
+ con = duckdb.connect(str(out / "work.duckdb"))
58
+ con.execute("SET memory_limit = '4GB'")
59
+ silver = load_silver(con, [cfg.data_dir, cfg.data_dir / "bronze"])
60
+ print(f"silver: {silver}", flush=True)
61
+ empty = [t for t in ("repo_meta", "prs", "issues") if not silver.get(t)]
62
+ if empty:
63
+ # A wrong store repo or token finds nothing; publishing that would replace the good bundle.
64
+ print(f"No crawl data for {', '.join(empty)}; not building signals.", file=sys.stderr)
65
+ con.close()
66
+ return None
67
+ print(f"signals: {build_all(con, now)}", flush=True)
68
+ return con
69
+
70
+
71
+ def write_bundle(con: duckdb.DuckDBPyConnection, out: Path) -> Path:
72
+ with tarfile.open(out / "signals.tar", "w") as tar:
73
+ for table in TABLES:
74
+ path = out / f"{table}.parquet"
75
+ quoted = path.as_posix().replace("'", "''")
76
+ con.execute(f"COPY {table} TO '{quoted}' (FORMAT parquet, COMPRESSION zstd)")
77
+ tar.add(path, arcname=path.name)
78
+ return out / "signals.tar"
79
+
80
+
81
+ def _validate(args) -> int:
82
+ import httpx
83
+
84
+ from mergescout.github.graphql import GitHubGraphQL
85
+ from mergescout.signals.check import run_check, run_opendigger
86
+ if args.cmd == "opendigger":
87
+ with httpx.Client(timeout=60) as http:
88
+ return 0 if run_opendigger(Path(args.signals), args.repos, http)["n"] else 1
89
+ token = load_config().require_token().strip()
90
+ with httpx.Client(timeout=60, headers={"Authorization": f"Bearer {token}"}) as http:
91
+ run_check(Path(args.signals), Path(args.out), GitHubGraphQL(token, max_wait=120), http)
92
+ return 0
93
+
94
+
95
+ if __name__ == "__main__":
96
+ raise SystemExit(main())
@@ -0,0 +1,258 @@
1
+ """The upgrade ledger: how Dependabot and Renovate PRs for each package ended across the crawl (merged, failed CI
2
+ against these repos' usual, needed extra commits, closed, reverted), with counts so readers can judge the evidence."""
3
+ import re
4
+ from datetime import datetime, timedelta
5
+
6
+ import duckdb
7
+ import pyarrow as pa
8
+
9
+ from mergescout.signals.bumps import MANAGERS, _norm, parse_bump
10
+
11
+ WINDOW_DAYS = 180
12
+ REVERT_DAYS = 14
13
+ # Backtest flags. Closed matches the dossier (40% predicted 3x later closes on the crawl sample); CI uses the raw
14
+ # failure share until enough CI results exist to test the dossier's comparison with each repo's usual rate.
15
+ CLOSED_FLAG = 0.40
16
+ CI_FLAG = 0.40
17
+ CUTOFFS = (0.30, 0.40, 0.50, 0.60)
18
+ # Cells this thin are left out of the published file; readers see "not enough evidence" below 30 PRs from 10 repos.
19
+ MIN_PUBLISH = 3
20
+ DEFINITION = ("Dependabot and Renovate PRs opened in the last 180 days in crawled repos, per ecosystem, package and step "
21
+ "(major, minor, patch; 0.x minor steps count as major). Merged; closed without merging (a bot closing its own "
22
+ "PR for a newer version is not counted); CI failed on the last commit, next to the failures these repos' "
23
+ "other bot PRs would predict; needed extra commits; reverted or rolled back within 14 days (a revert PR, GitHub's "
24
+ "revert branch, or a branch naming the rollback). Repos that never merge bot PRs "
25
+ "are left out. Package and versions come from titles, or from Dependabot branch names for older PRs.")
26
+ SUPERSEDE = timedelta(days=2)
27
+ # Renovate's "update dependency X" does not say npm or PyPI; the repo's main language does (marked as inferred).
28
+ LANGUAGE_ECOSYSTEM = {"Python": "pypi", "JavaScript": "npm", "TypeScript": "npm", "Vue": "npm", "Svelte": "npm",
29
+ "Astro": "npm", "CoffeeScript": "npm"}
30
+ # Renovate also names runners, runtimes and Docker images this way ("update dependency ubuntu to v24"): never guessed.
31
+ NOT_PACKAGES = {"ubuntu", "debian", "alpine", "centos", "fedora", "windows", "macos", "node", "nodejs", "node.js", "python",
32
+ "golang", "go", "rust", "ruby", "java", "openjdk", "temurin", "dotnet", "nginx", "httpd", "postgres",
33
+ "postgresql", "redis", "mysql", "mariadb", "mongo", "mongodb", "busybox", "docker", "kubernetes", "terraform"}
34
+ REVERT = re.compile(r'^\s*revert\s+"(?P<inner>.+)"\s*$', re.I)
35
+ BRANCH_VERSION = re.compile(r"^(?P<pkg>.+?)-(?P<ver>v?\d+(?:\.\d+)+[\w.+-]*)$")
36
+ RANGE_OPERATOR = re.compile(r"(?:-(?:gte|lte|gt|lt|neq|eq|tw|approx))+$")
37
+ # Names that can exist in each registry; anything else (a hook URL, "jdx/mise" read as PyPI) is not attributed.
38
+ VALID_NAME = {"pypi": re.compile(r"^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$", re.I),
39
+ "npm": re.compile(r"^(@[a-z0-9][\w.~-]*/)?[a-z0-9~][\w.~-]*$", re.I)}
40
+ FAILED = ("FAILURE", "ERROR")
41
+
42
+
43
+ def parse_branch(head: str | None) -> tuple[str, str, str] | None:
44
+ """(ecosystem, package, new version) from a Dependabot branch, only where it can't be misread: PyPI names never
45
+ contain "/", and an npm branch without folders; scoped npm names and folders look alike, so those are skipped."""
46
+ parts = (head or "").split("/")
47
+ if len(parts) < 3 or parts[0].lower() != "dependabot" or parts[-1].startswith("multi-"):
48
+ return None
49
+ eco, m = MANAGERS.get(parts[1]), BRANCH_VERSION.match(parts[-1])
50
+ if not m or eco not in ("pypi", "npm") or (eco == "npm" and len(parts) != 3):
51
+ return None
52
+ # Range updates put the operator in the branch: "setuptools-scm-gte-8.0.0", "boto3-approx-eq-1.35.0"; extras
53
+ # ("uvicorn[standard]" -> "uvicorn-standard-") can't be told apart from the name, so those are skipped.
54
+ name = RANGE_OPERATOR.sub("", m["pkg"])
55
+ return (eco, _norm(eco, name), m["ver"]) if valid_name(eco, name) and not name.endswith("-") else None
56
+
57
+
58
+ def valid_name(eco: str | None, name: str | None) -> bool:
59
+ rule = VALID_NAME.get(eco or "")
60
+ return bool(name) and (rule is None or bool(rule.match(name)))
61
+
62
+
63
+ def _key(name: str | None) -> str:
64
+ # Names compared loosely when matching a revert to its bump (the revert title has no ecosystem).
65
+ return re.sub(r"[-_.]+", "-", (name or "").lower())
66
+
67
+
68
+ def build_bump_prs(con: duckdb.DuckDBPyConnection) -> int:
69
+ """One row per dependency-bot PR: parsed from its title, else from its branch (crawl files before titles were kept)."""
70
+ rows = con.execute("""
71
+ SELECT p.repo_id, p.number, p.created_at, p.merged_at, p.closed_at, p.state, p.author_login, p.head_ref, p.title,
72
+ p.ci_state, p.commit_count, rm.language
73
+ FROM prs p LEFT JOIN repo_meta rm USING (repo_id)
74
+ WHERE regexp_matches(lower(coalesce(p.author_login, '')), '^(dependabot|renovate)')
75
+ OR regexp_matches(lower(coalesce(p.head_ref, '')), '^(dependabot|renovate)')""").fetchall()
76
+ out = []
77
+ for repo, number, created, merged, closed, state, login, head, title, ci, commits, lang in rows:
78
+ if title:
79
+ b, source = parse_bump(title, head, login), "title"
80
+ if b is None:
81
+ continue # Renovate's setup and pin PRs are not upgrades
82
+ else:
83
+ found = parse_branch(head)
84
+ if not found:
85
+ continue
86
+ b = {"bot": "dependabot", "ecosystem": found[0], "package": found[1], "from_version": None,
87
+ "to_version": found[2], "bump": None, "grouped": False, "security": False, "closed_by_bot": False}
88
+ source = "branch"
89
+ inferred = b["ecosystem"] is None and b["package"] is not None and lang in LANGUAGE_ECOSYSTEM \
90
+ and b["package"].lower() not in NOT_PACKAGES
91
+ eco = LANGUAGE_ECOSYSTEM[lang] if inferred else b["ecosystem"]
92
+ if b["package"] and not valid_name(eco, b["package"]):
93
+ b = {**b, "package": None}
94
+ out.append({"repo_id": repo, "number": number, "created_at": created, "merged_at": merged, "closed_at": closed,
95
+ "state": state, "bot": b["bot"], "ecosystem": eco, "package": _norm(eco, b["package"]) if b["package"] else None,
96
+ "from_version": b["from_version"], "to_version": b["to_version"], "bump": b["bump"],
97
+ "grouped": b["grouped"], "security": b["security"], "ci_state": ci, "commit_count": commits,
98
+ "superseded": bool(b["closed_by_bot"]), "reverted": False, "eco_inferred": inferred, "source": source})
99
+ _mark_superseded(out)
100
+ _mark_reverts(con, out)
101
+ con.register("bump_prs_rows", pa.Table.from_pylist(out, schema=SCHEMA))
102
+ con.execute("CREATE OR REPLACE TABLE bump_prs AS SELECT * FROM bump_prs_rows")
103
+ con.unregister("bump_prs_rows")
104
+ return len(out)
105
+
106
+
107
+ def _mark_superseded(rows: list[dict]) -> None:
108
+ # A bot closes its own PR when a newer version arrives: a close next to a new PR for the same package isn't a rejection.
109
+ opened = {}
110
+ for r in rows:
111
+ if r["package"]:
112
+ opened.setdefault((r["repo_id"], r["package"]), []).append(r["created_at"])
113
+ for r in rows:
114
+ if r["package"] and r["merged_at"] is None and r["closed_at"] is not None:
115
+ later = [t for t in opened[(r["repo_id"], r["package"])] if t > r["created_at"]]
116
+ if any(abs(t - r["closed_at"]) <= SUPERSEDE for t in later):
117
+ r["superseded"] = True
118
+
119
+
120
+ def _mark_reverts(con: duckdb.DuckDBPyConnection, rows: list[dict]) -> None:
121
+ # A merged 'Revert "Bump X from A to B"' within 14 days of the bump's merge is the strongest sign it broke something.
122
+ reverts = con.execute("""SELECT repo_id, merged_at, title FROM prs
123
+ WHERE merged_at IS NOT NULL AND title IS NOT NULL AND lower(title) LIKE 'revert%'""").fetchall()
124
+ by_bump = {}
125
+ for r in rows:
126
+ if r["package"] and r["merged_at"] is not None:
127
+ by_bump.setdefault((r["repo_id"], _key(r["package"]), r["to_version"]), []).append(r)
128
+ for repo, when, title in reverts:
129
+ m = REVERT.match(title)
130
+ inner = parse_bump(m["inner"], "dependabot/", "dependabot") if m else None
131
+ if not inner or not inner["package"]:
132
+ continue
133
+ for r in by_bump.get((repo, _key(inner["package"]), inner["to_version"]), []):
134
+ if r["merged_at"] <= when <= r["merged_at"] + timedelta(days=REVERT_DAYS):
135
+ r["reverted"] = True
136
+ _mark_branch_rollbacks(con, rows)
137
+
138
+
139
+ # GitHub's Revert button names the branch after the PR it undoes: revert-<number>-<that PR's branch>.
140
+ REVERT_BRANCH = re.compile(r"(?i)^revert-(\d+)-")
141
+
142
+
143
+ def _branch_name(package: str) -> str:
144
+ """How a branch names the package: a scoped npm name needs both parts (@types/node as types-node, never node), a Go
145
+ module its last part without a /v2 suffix, any other name all of it."""
146
+ parts = package.lstrip("@").split("/")
147
+ if len(parts) > 1 and re.fullmatch(r"v\d+", parts[-1]):
148
+ parts = parts[:-1]
149
+ if package.startswith("@"):
150
+ return "[-/_]".join(re.escape(p) for p in parts)
151
+ return re.escape(parts[-1])
152
+
153
+
154
+ def _mark_branch_rollbacks(con: duckdb.DuckDBPyConnection, rows: list[dict]) -> None:
155
+ """Rollbacks the crawl keeps no title for, from the merged PR's branch: GitHub's revert-<number> branches, and
156
+ branches naming a rollback of the package followed by a version or nothing (rollback-litellm, downgrade-esbuild-0.24);
157
+ on the crawl's sample this found 27 against the titles' 6 (bench/accuracy/deps)."""
158
+ merged = {(r["repo_id"], r["number"]): r for r in rows if r["merged_at"] is not None}
159
+ by_repo = {}
160
+ for r in merged.values():
161
+ if r["package"]:
162
+ by_repo.setdefault(r["repo_id"], []).append(r)
163
+ found = con.execute("""SELECT repo_id, merged_at, head_ref FROM prs
164
+ WHERE merged_at IS NOT NULL AND head_ref IS NOT NULL
165
+ AND regexp_matches(lower(head_ref), '(revert|downgrade|roll-?back)')""").fetchall()
166
+ window = timedelta(days=REVERT_DAYS)
167
+ for repo, when, ref in found:
168
+ if m := REVERT_BRANCH.match(ref):
169
+ r = merged.get((repo, int(m[1])))
170
+ if r and r["merged_at"] <= when <= r["merged_at"] + window:
171
+ r["reverted"] = True
172
+ continue
173
+ for r in by_repo.get(repo, ()):
174
+ name = _branch_name(r["package"])
175
+ if r["merged_at"] <= when <= r["merged_at"] + window and re.search(
176
+ rf"(?i)(revert|downgrade|roll-?back)[-/_]+{name}([-/_]v?\d|$)", ref):
177
+ r["reverted"] = True
178
+
179
+
180
+ SCHEMA = pa.schema([
181
+ ("repo_id", pa.int64()), ("number", pa.int32()), ("created_at", pa.timestamp("us", tz="UTC")),
182
+ ("merged_at", pa.timestamp("us", tz="UTC")), ("closed_at", pa.timestamp("us", tz="UTC")), ("state", pa.string()),
183
+ ("bot", pa.string()), ("ecosystem", pa.string()), ("package", pa.string()), ("from_version", pa.string()),
184
+ ("to_version", pa.string()), ("bump", pa.string()), ("grouped", pa.bool_()), ("security", pa.bool_()),
185
+ ("ci_state", pa.string()), ("commit_count", pa.int32()), ("superseded", pa.bool_()), ("reverted", pa.bool_()),
186
+ ("eco_inferred", pa.bool_()), ("source", pa.string())])
187
+
188
+
189
+ def build_bump_ledger(con: duckdb.DuckDBPyConnection, now: datetime, window_days: int = WINDOW_DAYS) -> int:
190
+ """Per ecosystem, package and step size over the window: outcomes with counts, and the CI failures these repos' other
191
+ bot PRs would predict (each PR's repo rate, leaving the PR out), so a flaky repo doesn't blame a package."""
192
+ con.execute("""
193
+ CREATE OR REPLACE TABLE bump_ledger AS
194
+ WITH quiet AS (
195
+ -- Repos that never merge bot PRs ignore them; their closes say nothing about the package.
196
+ SELECT repo_id FROM bump_prs GROUP BY repo_id HAVING count(*) >= 10 AND count(merged_at) = 0),
197
+ base AS (
198
+ SELECT repo_id, count(ci_state) AS n, count(*) FILTER (WHERE ci_state IN ('FAILURE', 'ERROR')) AS f
199
+ FROM bump_prs GROUP BY repo_id),
200
+ b AS (
201
+ SELECT p.*, (p.ci_state IN ('FAILURE', 'ERROR'))::INT AS failed, base.n, base.f
202
+ FROM bump_prs p JOIN base USING (repo_id)
203
+ WHERE p.package IS NOT NULL AND p.created_at >= $since AND p.repo_id NOT IN (SELECT repo_id FROM quiet))
204
+ SELECT ecosystem, package, coalesce(bump, 'unknown') AS bump,
205
+ count(*) AS prs, count(DISTINCT repo_id) AS repos,
206
+ count(merged_at) AS merged,
207
+ count(*) FILTER (WHERE merged_at IS NULL AND state = 'CLOSED' AND NOT superseded) AS closed_unmerged,
208
+ count(ci_state) AS ci_known, sum(failed) FILTER (WHERE ci_state IS NOT NULL) AS ci_failed,
209
+ sum((f - failed)::DOUBLE / (n - 1)) FILTER (WHERE ci_state IS NOT NULL AND n > 1) AS expected_failed,
210
+ count(*) FILTER (WHERE commit_count > 1) AS extra_commits,
211
+ count(*) FILTER (WHERE reverted) AS reverted,
212
+ bool_or(eco_inferred) AS eco_inferred, min(created_at) AS first_at, max(created_at) AS last_at
213
+ FROM b GROUP BY ALL""", {"since": now - timedelta(days=window_days)})
214
+ return con.execute("SELECT count(*) FROM bump_ledger").fetchall()[0][0]
215
+
216
+
217
+ def ledger_json(con: duckdb.DuckDBPyConnection, now: datetime) -> dict:
218
+ """The ledger as the published file: every cell with a few PRs, with its counts."""
219
+ cols = ("ecosystem", "package", "bump", "prs", "repos", "merged", "closed_unmerged", "ci_known", "ci_failed",
220
+ "expected_failed", "extra_commits", "reverted", "eco_inferred")
221
+ rows = con.execute(f"SELECT {', '.join(cols)} FROM bump_ledger WHERE prs >= $min ORDER BY ecosystem, package, bump",
222
+ {"min": MIN_PUBLISH}).fetchall()
223
+ cells = [{k: (round(v, 2) if isinstance(v, float) else v) for k, v in zip(cols, r)} for r in rows]
224
+ return {"built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "definition": DEFINITION, "window_days": WINDOW_DAYS,
225
+ "cells": cells}
226
+
227
+
228
+ def bump_backtest(con: duckdb.DuckDBPyConnection, now: datetime) -> dict:
229
+ """Did packages flagged on older upgrades (opened 360-120 days ago) see more trouble on later ones (90-30 days ago)?
230
+ Logged by the daily build so the dossier's thresholds are checked as evidence grows."""
231
+ def cells(lo: int, hi: int) -> dict:
232
+ rows = con.execute("""
233
+ SELECT ecosystem, package, count(*), count(DISTINCT repo_id),
234
+ count(*) FILTER (WHERE merged_at IS NULL AND state = 'CLOSED' AND NOT superseded),
235
+ count(*) FILTER (WHERE merged_at IS NOT NULL OR (state = 'CLOSED' AND NOT superseded)),
236
+ count(ci_state), count(*) FILTER (WHERE ci_state IN ('FAILURE', 'ERROR'))
237
+ FROM bump_prs WHERE package IS NOT NULL AND created_at >= $lo AND created_at < $hi GROUP BY ALL""",
238
+ {"lo": now - timedelta(days=lo), "hi": now - timedelta(days=hi)}).fetchall()
239
+ return {(r[0], r[1]): r[2:] for r in rows}
240
+ early, later = cells(360, 120), cells(90, 30)
241
+ strong = {k for k, v in early.items() if v[0] >= 30 and v[1] >= 10}
242
+ out = {}
243
+ for name, cut, flag, num, den in (("closed", CLOSED_FLAG, lambda v, c: v[3] and v[2] / v[3] >= c, 2, 3),
244
+ ("ci", CI_FLAG, lambda v, c: v[4] >= 20 and v[5] / v[4] >= c, 5, 4)):
245
+ def share(keys, num=num, den=den):
246
+ d = sum(later[k][den] for k in keys if k in later)
247
+ return round(sum(later[k][num] for k in keys if k in later) / d, 3) if d else None
248
+
249
+ def run(c, flag=flag):
250
+ flagged = {k for k in strong if flag(early[k], c)}
251
+ f, o = share(flagged), share(strong - flagged)
252
+ return {"flagged": len(flagged), "later_flagged": f, "later_others": o,
253
+ "ratio": round(f / o, 2) if f is not None and o else None}
254
+ # Every cutoff, so the dossier's threshold is picked from data (40% gave 1.72x on 2026-10-19, below the 2x bar).
255
+ out[name] = {"strong": len(strong), **run(cut),
256
+ "by_cutoff": {f"{int(c * 100)}%": {k: v for k, v in run(c).items() if k in ("flagged", "ratio")}
257
+ for c in CUTOFFS}}
258
+ return out
@@ -0,0 +1,104 @@
1
+ """Dependency-update PRs (Dependabot, Renovate) read from their titles and branch names: which package, which versions,
2
+ how big a step. The upgrade ledger counts how these PRs ended across the crawl."""
3
+ import re
4
+
5
+ from mergescout.crawl.queries import is_dep_bot
6
+
7
+ # Dependabot's branch says the package manager: dependabot/<manager>/<path>/<package>-<version>.
8
+ MANAGERS = {"npm_and_yarn": "npm", "npm": "npm", "bun": "npm", "pip": "pypi", "pipenv": "pypi", "poetry": "pypi",
9
+ "uv": "pypi", "pip_compile": "pypi", "github_actions": "actions", "cargo": "cargo", "go_modules": "go",
10
+ "maven": "maven", "gradle": "maven", "bundler": "rubygems", "composer": "packagist", "nuget": "nuget",
11
+ "docker": "docker"}
12
+ # Renovate names the kind of dependency in its title ("update rust crate serde", "update actions/checkout action").
13
+ RENOVATE_KINDS = {"rust crate": "cargo", "module": "go", "action": "actions", "docker tag": "docker", "docker digest": "docker",
14
+ "digest": None, "container image": "docker", "image": "docker", "helm release": "helm", "chart": "helm",
15
+ "gem": "rubygems", "plugin": None, "pre-commit hook": None, "package": None, "dependency": None}
16
+ KINDS = "rust crate|module|docker tag|docker digest|container image|image|helm release|chart|gem|plugin|pre-commit hook|package|dependency"
17
+ WHATS = "action|monorepo|packages|docker tag|docker digest|digest"
18
+
19
+ # Leading noise: emoji and a "chore(ci)(deps):"-style prefix of any language; "[Security]" tags are removed first.
20
+ PREFIX = re.compile(r"^\W*(?:[^:]{1,40}?:\s+)?")
21
+ SECURITY = re.compile(r"\s*\[security\]\s*", re.I)
22
+ AUTOCLOSED = re.compile(r"\s+-\s+autoclosed$", re.I)
23
+ BASE_BRANCH = re.compile(r"\s+\((?!major\))[^()]*\)$", re.I)
24
+ NOT_UPDATE = re.compile(r"^(?:configure renovate|migrate renovate config|pin\b)", re.I)
25
+
26
+ DEPENDABOT = re.compile(r"^bump (?P<pkg>\S+) from (?P<old>\S+) to (?P<new>\S+)(?: in .*)?$", re.I)
27
+ DEPENDABOT_RANGE = re.compile(r"^update (?P<pkg>\S+) requirement from (?P<old>.+?) to (?P<new>.+?)(?: in .*)?$", re.I)
28
+ DEPENDABOT_GROUP = re.compile(r"^bump the \S+ group|^bump \S+(?:,| and )|^bump \S+ packages from", re.I)
29
+ ARROW = re.compile(rf"^update (?:(?P<kind>{KINDS}) )?(?P<pkg>\S+) \(\s*(?P<old>\S+)\s*(?:→|➔|->)\s*(?P<new>\S+)\s*\)", re.I)
30
+ RENOVATE = re.compile(rf"^update (?:(?P<kind>{KINDS}) )?(?P<pkg>\S+)(?: (?P<what>{WHATS}))?(?: from (?P<old>\S+))? to (?P<new>\S+)",
31
+ re.I)
32
+ RENOVATE_GROUP = re.compile(r"^update all |^lock file maintenance|monorepo", re.I)
33
+ VERSION = re.compile(r"^[\^~=<>]*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?")
34
+
35
+
36
+ def _norm(eco: str | None, name: str) -> str:
37
+ # PyPI names compare case- and separator-insensitively (PEP 503); others are kept as written.
38
+ return re.sub(r"[-_.]+", "-", name.lower()) if eco == "pypi" else name
39
+
40
+
41
+ def _version(text: str) -> str:
42
+ # "v14.0.3" -> "14.0.3"; ranges ("^0.57.0") stay as written.
43
+ return text[1:] if re.match(r"^v\d", text) else text
44
+
45
+
46
+ def bump_size(old: str | None, new: str | None) -> str | None:
47
+ """major / minor / patch from two versions; 0.x minor steps count as major, since semver lets them break."""
48
+ a, b = VERSION.match(old or ""), VERSION.match(new or "")
49
+ if not a or not b:
50
+ return None
51
+ x = [int(v or 0) for v in a.groups()]
52
+ y = [int(v or 0) for v in b.groups()]
53
+ if x[0] != y[0] or (x[0] == 0 and x[1] != y[1]):
54
+ return "major"
55
+ return "minor" if x[1] != y[1] else "patch"
56
+
57
+
58
+ def parse_bump(title: str | None, head: str | None, login: str | None) -> dict | None:
59
+ """One dependency-update PR as {bot, ecosystem, package, from_version, to_version, bump, grouped, security,
60
+ closed_by_bot}, or None when it isn't an update (another author, Renovate's setup or pin PRs)."""
61
+ if not is_dep_bot(login, head) or not title:
62
+ return None
63
+ bot = "dependabot" if (head or "").lower().startswith("dependabot") or (login or "").lower().startswith("dependabot") \
64
+ else "renovate"
65
+ text = SECURITY.sub(" ", title).strip()
66
+ closed_by_bot = AUTOCLOSED.search(text) is not None
67
+ text = PREFIX.sub("", AUTOCLOSED.sub("", text)).strip()
68
+ if NOT_UPDATE.match(text):
69
+ return None
70
+ out = {"bot": bot, "ecosystem": None, "package": None, "from_version": None, "to_version": None, "bump": None,
71
+ "grouped": False, "security": SECURITY.search(title) is not None, "closed_by_bot": closed_by_bot}
72
+ if bot == "dependabot":
73
+ parts = (head or "").split("/")
74
+ eco = out["ecosystem"] = MANAGERS.get(parts[1]) if len(parts) > 1 else None
75
+ # One package in several folders comes on a "multi-" branch without versions in the title.
76
+ if DEPENDABOT_GROUP.match(text) or "/multi-" in (head or ""):
77
+ return {**out, "grouped": True}
78
+ if m := DEPENDABOT.match(text):
79
+ return {**out, "package": _norm(eco, m["pkg"]), "from_version": m["old"], "to_version": m["new"],
80
+ "bump": bump_size(m["old"], m["new"])}
81
+ if m := DEPENDABOT_RANGE.match(text):
82
+ return {**out, "package": _norm(eco, m["pkg"]), "from_version": m["old"], "to_version": m["new"], "bump": "range"}
83
+ return out
84
+ if m := ARROW.match(text):
85
+ eco = RENOVATE_KINDS.get((m["kind"] or "image").lower())
86
+ return {**out, "ecosystem": eco, "package": _norm(eco, m["pkg"]), "from_version": _version(m["old"]),
87
+ "to_version": _version(m["new"]), "bump": bump_size(m["old"], m["new"])}
88
+ text = BASE_BRANCH.sub("", text)
89
+ grouped = RENOVATE_GROUP.search(text) is not None
90
+ m = RENOVATE.match(text)
91
+ if not m:
92
+ # A Renovate update without "to <version>" is a named group ("update frontend dependencies").
93
+ return {**out, "grouped": True}
94
+ kind = (m["what"] or m["kind"] or "dependency").lower()
95
+ eco, new = RENOVATE_KINDS.get(kind), _version(m["new"])
96
+ if "digest" in kind:
97
+ bump = "digest"
98
+ elif m["old"]:
99
+ bump = bump_size(m["old"], new)
100
+ else:
101
+ # Renovate writes only the new major for major updates ("to v19"), the full version otherwise.
102
+ bump = "major" if "(major)" in text.lower() or re.fullmatch(r"v?\d+", m["new"]) else "nonmajor"
103
+ return {**out, "ecosystem": eco, "package": None if grouped else _norm(eco, m["pkg"]), "grouped": grouped,
104
+ "from_version": _version(m["old"]) if m["old"] else None, "to_version": new, "bump": bump}
@@ -0,0 +1,141 @@
1
+ import csv
2
+ import random
3
+ import re
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ import duckdb
9
+ import httpx
10
+
11
+ from mergescout.crawl.queries import RL
12
+ from mergescout.owners import owner_logins
13
+
14
+ OWNER_FILES = (".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS", "MAINTAINERS", "MAINTAINERS.md", "OWNERS")
15
+
16
+
17
+ def sample_repos(repo_stats: list[dict], n: int = 30, seed: int = 7) -> list[int]:
18
+ tiers = [(100, 999), (1000, 9999), (10000, None)]
19
+ rng = random.Random(seed)
20
+ picked = []
21
+ for lo, hi in tiers:
22
+ pool = sorted(r["repo_id"] for r in repo_stats if r["outsider_prs"] >= 20 and r["stars"] >= lo
23
+ and (hi is None or r["stars"] <= hi))
24
+ picked += rng.sample(pool, min(n // len(tiers), len(pool)))
25
+ return picked
26
+
27
+
28
+ def score_identity(predicted: dict[str, str], confirmed_insiders: set[str], pr_counts: dict[str, int]) -> dict:
29
+ insiders = [u for u, k in predicted.items() if k == "insider"]
30
+ missed = sorted(u for u, k in predicted.items() if k == "outsider" and u in confirmed_insiders)
31
+ outsider_prs = sum(pr_counts.get(u, 0) for u, k in predicted.items() if k == "outsider")
32
+ confirmed = [u for u in insiders if u in confirmed_insiders]
33
+ # Unconfirmed insiders are not errors: most employees keep their org membership private.
34
+ return {"insider_confirmed": len(confirmed), "insider_checkable": len(confirmed),
35
+ "insider_unconfirmed": len(insiders) - len(confirmed), "missed_insiders": missed,
36
+ "missed_pr_share": (sum(pr_counts.get(u, 0) for u in missed) / outsider_prs) if outsider_prs else 0.0}
37
+
38
+
39
+ def spearman(a: list[float], b: list[float]) -> float:
40
+ def ranks(xs):
41
+ order = sorted(range(len(xs)), key=lambda i: xs[i])
42
+ r = [0.0] * len(xs)
43
+ for rank, i in enumerate(order):
44
+ r[i] = float(rank)
45
+ return r
46
+ ra, rb = ranks(a), ranks(b)
47
+ n = len(a)
48
+ d2 = sum((x - y) ** 2 for x, y in zip(ra, rb))
49
+ return 1 - 6 * d2 / (n * (n * n - 1))
50
+
51
+
52
+ OWNER_QUERY = "query($owner: String!, $name: String!) { " + RL + " repository(owner: $owner, name: $name) { " + " ".join(
53
+ f'f{i}: object(expression: "HEAD:{f}") {{ ... on Blob {{ text }} }}' for i, f in enumerate(OWNER_FILES)) + " } }"
54
+
55
+
56
+ def _rows(con: duckdb.DuckDBPyConnection, sql: str) -> list[dict]:
57
+ cur = con.execute(sql)
58
+ cols = [d[0] for d in cur.description]
59
+ return [dict(zip(cols, r)) for r in cur.fetchall()]
60
+
61
+
62
+ def _parquet(signals: Path, table: str) -> str:
63
+ quoted = (signals / f"{table}.parquet").as_posix().replace("'", "''")
64
+ return f"read_parquet('{quoted}')"
65
+
66
+
67
+ def run_check(signals: Path, out: Path, gql, http: httpx.Client, log=print) -> dict:
68
+ con = duckdb.connect()
69
+ stats = _rows(con, f"SELECT repo_id, name, stars, outsider_prs FROM {_parquet(signals, 'repo_stats')} "
70
+ "WHERE name IS NOT NULL AND stars IS NOT NULL AND outsider_prs IS NOT NULL")
71
+ people = _rows(con, f"SELECT repo_id, lower(login) AS login, kind, prs, activity FROM {_parquet(signals, 'people')} "
72
+ "WHERE kind IN ('insider', 'outsider')")
73
+ by_repo = {}
74
+ for p in people:
75
+ by_repo.setdefault(p["repo_id"], []).append(p)
76
+ names = {s["repo_id"]: s for s in stats}
77
+ rows, missed_prs, outsider_prs = [], 0, 0
78
+ for repo_id in sample_repos(stats):
79
+ repo = names[repo_id]
80
+ owner, name = repo["name"].split("/", 1)
81
+ data = gql.query(OWNER_QUERY, {"owner": owner, "name": name}).data["repository"] or {}
82
+ texts = [(data.get(f"f{i}") or {}).get("text") or "" for i in range(len(OWNER_FILES))]
83
+ confirmed = owner_logins("\n".join(texts)) | {owner.lower()}
84
+ ppl = by_repo.get(repo_id, [])
85
+ top = [p["login"] for kind in ("insider", "outsider")
86
+ for p in sorted((p for p in ppl if p["kind"] == kind), key=lambda p: -p["activity"])[:20]]
87
+
88
+ def member(login, owner=owner):
89
+ # 204 means a public member of the owning org (users and private members answer 404).
90
+ return login, http.get(f"https://api.github.com/orgs/{owner}/public_members/{login}").status_code == 204
91
+ with ThreadPoolExecutor(8) as pool:
92
+ confirmed |= {login for login, yes in pool.map(member, [u for u in top if u not in confirmed]) if yes}
93
+ s = score_identity({p["login"]: p["kind"] for p in ppl}, confirmed, {p["login"]: p["prs"] for p in ppl})
94
+ repo_outsider_prs = sum(p["prs"] for p in ppl if p["kind"] == "outsider")
95
+ missed_prs += sum(p["prs"] for p in ppl if p["login"] in s["missed_insiders"])
96
+ outsider_prs += repo_outsider_prs
97
+ rows.append({"repo": repo["name"], "stars": repo["stars"],
98
+ "insiders": sum(p["kind"] == "insider" for p in ppl),
99
+ "outsiders": sum(p["kind"] == "outsider" for p in ppl),
100
+ "owner_files": sum(bool(x) for x in texts),
101
+ "insider_confirmed": s["insider_confirmed"], "insider_unconfirmed": s["insider_unconfirmed"],
102
+ "missed_insiders": " ".join(s["missed_insiders"]), "missed_pr_share": round(s["missed_pr_share"], 4)})
103
+ log(f"{repo['name']}: {s['insider_confirmed']} insiders confirmed, missed {s['missed_insiders']} "
104
+ f"({s['missed_pr_share']:.1%} of outsider PRs)")
105
+ out.parent.mkdir(parents=True, exist_ok=True)
106
+ with out.open("w", newline="", encoding="utf-8") as f:
107
+ writer = csv.DictWriter(f, fieldnames=list(rows[0]) if rows else ["repo"])
108
+ writer.writeheader()
109
+ writer.writerows(rows)
110
+ total = {"repos": len(rows), "missed_pr_share": missed_prs / outsider_prs if outsider_prs else 0.0}
111
+ log(f"total: {total['repos']} repos, missed insiders wrote {total['missed_pr_share']:.2%} of outsider PRs")
112
+ return total
113
+
114
+
115
+ def _last_month_median(doc: dict, now: datetime) -> float | None:
116
+ # OpenDigger keys months as "YYYY-MM"; the current month is still incomplete.
117
+ q = doc.get("quantile_2") or {}
118
+ months = sorted(k for k in q if re.fullmatch(r"\d{4}-\d{2}", k) and k < now.strftime("%Y-%m"))
119
+ return q[months[-1]] if months else None
120
+
121
+
122
+ def run_opendigger(signals: Path, repos: int, http: httpx.Client, log=print, now: datetime | None = None) -> dict:
123
+ now = now or datetime.now(timezone.utc)
124
+ con = duckdb.connect()
125
+ ours = _rows(con, f"SELECT name, median_review_hours FROM {_parquet(signals, 'repo_stats')} "
126
+ f"WHERE review_enough AND name IS NOT NULL ORDER BY reviewed DESC LIMIT {int(repos)}")
127
+
128
+ def fetch(row):
129
+ resp = http.get(f"https://oss.open-digger.cn/github/{row['name']}/change_request_response_time.json")
130
+ return row, (resp.json() if resp.status_code == 200 else None)
131
+ with ThreadPoolExecutor(8) as pool:
132
+ results = list(pool.map(fetch, ours))
133
+ sample = next((doc for _, doc in results if doc), None)
134
+ if sample is None or "quantile_2" not in sample:
135
+ log(f"OpenDigger has no usable data (keys: {sorted(sample or {})[:12]})")
136
+ return {"n": 0, "spearman": None}
137
+ pairs = [(row["median_review_hours"], _last_month_median(doc, now) * 24) for row, doc in results
138
+ if doc and _last_month_median(doc, now) is not None]
139
+ rho = spearman([a for a, _ in pairs], [b for _, b in pairs]) if len(pairs) > 2 else None
140
+ log(f"OpenDigger: {len(pairs)} of {len(ours)} repos matched, Spearman {None if rho is None else round(rho, 3)}")
141
+ return {"n": len(pairs), "spearman": rho}