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,230 @@
1
+ """A public report from the crawl's published benchmark files: aggregates only (no repo or person named), and every
2
+ number in a CSV beside it so anyone can recompute it."""
3
+ import argparse
4
+ import csv
5
+ import json
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ MIN_REPOS = 50
10
+ LICENCE = "CC BY 4.0"
11
+ QS = ("p10", "p25", "p50", "p75", "p90", "p50_low", "p50_high")
12
+ METRICS = {"ci": ("median_s", "p90_s", "first_try_share", "rerun_share", "fail_share"),
13
+ "flow": ("first_review_hours", "merge_hours", "outsider_merge_rate", "size_lines"),
14
+ "delivery": ("pickup_p75_hours", "review_p75_hours", "cycle_to_merge_p75_hours"),
15
+ "deploys": ("days_per_week", "days_per_month"),
16
+ "community": ("first_answer_hours", "answered_1d_share", "newcomer_answered_share", "newcomer_return_rate"),
17
+ "stars": ("gained_12w", "growth_12w")}
18
+ RECOMPUTE_SQL = """```sql
19
+ -- DuckDB, in the folder with the CSV files: the typical repo's pickup P75 by language, with its range
20
+ SELECT language, repos, pickup_p75_hours_p50, pickup_p75_hours_p50_low, pickup_p75_hours_p50_high,
21
+ pickup_p75_hours_linearb_good
22
+ FROM read_csv_auto('delivery.csv') WHERE size = 'all' ORDER BY repos DESC;
23
+ ```"""
24
+ RECOMPUTE = """```python
25
+ import csv
26
+ rows = list(csv.DictReader(open("ci.csv", encoding="utf-8")))
27
+ rust = next(r for r in rows if r["language"] == "Rust" and r["size"] == "all")
28
+ print(float(rust["median_s_p50"]) / 60, "minutes") # the typical Rust repo's push wait
29
+ ```"""
30
+
31
+
32
+ def _load(folder: Path, name: str) -> dict | None:
33
+ try:
34
+ return json.loads((folder / f"{name}-benchmarks.json").read_text(encoding="utf-8"))
35
+ except (OSError, ValueError):
36
+ return None
37
+
38
+
39
+ def _rows(data: dict, metrics) -> list[dict]:
40
+ out = []
41
+ for key, g in sorted((data.get("groups") or {}).items()):
42
+ if not isinstance(g, dict) or (g.get("repos") or 0) < MIN_REPOS:
43
+ continue
44
+ lang, size = key.split("|", 1)
45
+ row = {"language": lang, "size": size, "repos": g["repos"]}
46
+ for kind in ("sources", "tiers"):
47
+ if isinstance(g.get(kind), dict):
48
+ row.update({f"{kind}_{k.replace(' ', '_')}": v for k, v in g[kind].items()})
49
+ for m in metrics:
50
+ q = g.get(m) or {}
51
+ row[f"{m}_repos"] = q.get("repos", "")
52
+ row.update({f"{m}_{p}": q.get(p, "") for p in QS})
53
+ if isinstance(q.get("linearb"), dict):
54
+ row.update({f"{m}_linearb_{band}": q["linearb"].get(band, "") for band in ("elite", "good", "fair")})
55
+ out.append(row)
56
+ return out
57
+
58
+
59
+ def _write_csv(path: Path, rows: list[dict]) -> None:
60
+ if not rows:
61
+ path.unlink(missing_ok=True) # an earlier build's file would no longer match the report
62
+ return
63
+ # Groups can carry different measures (a band share only where the group had the measure): every column, in order.
64
+ fields = list(dict.fromkeys(k for r in rows for k in r))
65
+ with path.open("w", encoding="utf-8", newline="") as f:
66
+ w = csv.DictWriter(f, fieldnames=fields, restval="")
67
+ w.writeheader()
68
+ w.writerows(rows)
69
+
70
+
71
+ def _min(secs) -> str:
72
+ return "-" if secs in ("", None) else f"{float(secs) / 60:.1f} min"
73
+
74
+
75
+ def _hrs(h) -> str:
76
+ return "-" if h in ("", None) else (f"{float(h):.0f} h" if float(h) < 48 else f"{float(h) / 24:.1f} days")
77
+
78
+
79
+ def _hrs1(h) -> str:
80
+ return "-" if h in ("", None) else (f"{float(h):.1f} h" if float(h) < 48 else f"{float(h) / 24:.1f} days")
81
+
82
+
83
+ def _pct(x) -> str:
84
+ return "-" if x in ("", None) else f"{float(x):.0%}"
85
+
86
+
87
+ def _table(rows: list[dict], by: str, cols: list[tuple[str, str, object]]) -> list[str]:
88
+ pick = [r for r in rows if (r["size"] == "all") != (by == "size") and (r["language"] == "all") == (by == "size")]
89
+ if not pick:
90
+ return []
91
+ head = ["Language" if by == "language" else "Stars", "Repos"] + [c[0] for c in cols]
92
+ lines = ["| " + " | ".join(head) + " |", "|" + "---|" * len(head)]
93
+ tiers = ["100-1k", "1k-10k", "10k+"]
94
+ order = (lambda r: -int(r["repos"])) if by == "language" else (lambda r: tiers.index(r["size"]) if r["size"] in tiers else 9)
95
+ for r in sorted(pick, key=order):
96
+ name = r["language"] if by == "language" else r["size"]
97
+ lines.append(f"| {name} | {int(r['repos']):,} | " + " | ".join(fmt(r[key]) for _, key, fmt in cols) + " |")
98
+ return lines
99
+
100
+
101
+ def build_report(bench: Path, out: Path) -> Path:
102
+ """report.md plus one CSV per benchmark file in `out`."""
103
+ bench, out = Path(bench), Path(out)
104
+ out.mkdir(parents=True, exist_ok=True)
105
+ data = {name: _load(bench, name) for name in METRICS}
106
+ rows = {}
107
+ for name, d in data.items():
108
+ if d:
109
+ rows[name] = _rows(d, METRICS[name])
110
+ _write_csv(out / f"{name}.csv", rows[name])
111
+ ci, flow, com = data.get("ci") or {}, data.get("flow") or {}, data.get("community") or {}
112
+ built = max((d.get("built_at") or "" for d in data.values() if d), default="")[:10] or "date unknown"
113
+ month = datetime.fromisoformat(built).strftime("%B %Y") if built[:1].isdigit() else ""
114
+ newcomers = com.get("newcomers") or {}
115
+ lines = [f"# State of open-source CI and review flow{f' ({month})' if month else ''}", "",
116
+ "From a daily crawl of public GitHub repos with 100+ stars: "
117
+ + ", ".join(x for x in (f"CI from {ci['repos']:,} repos" if ci else "",
118
+ f"review flow from {flow['repos']:,} repos" if flow else "",
119
+ f"first answers from {com['repos']:,} repos" if com else "",
120
+ f"{newcomers['newcomers']:,} newcomers in {newcomers['repos']:,} repos" if newcomers else "") if x)
121
+ + f". Aggregates only: no repo or person is named. Every number below is in the CSV files next to this report, "
122
+ f"under {LICENCE}. Groups with fewer than {MIN_REPOS} repos are left out. Built {built}.", ""]
123
+ all_ci = next((r for r in rows.get("ci", []) if r["language"] == "all" and r["size"] == "all"), None)
124
+ if all_ci:
125
+ lines += ["## CI: how long a push waits for its checks", "",
126
+ f"The typical repo (the median repo) waits {_min(all_ci['median_s_p50'])} from a push until all its checks "
127
+ f"finish; one in ten repos waits more than {_min(all_ci['median_s_p90'])}. The typical repo's pushes are green "
128
+ f"on the first try {_pct(all_ci['first_try_share_p50'])} of the time ({int(all_ci['repos']):,} repos).", ""]
129
+ cols = [("Typical push wait", "median_s_p50", _min), ("Slowest 10% of repos", "median_s_p90", _min),
130
+ ("Green on first try", "first_try_share_p50", _pct)]
131
+ lines += _table(rows["ci"], "language", cols) + [""] + _table(rows["ci"], "size", cols) + [""]
132
+ all_flow = next((r for r in rows.get("flow", []) if r["language"] == "all" and r["size"] == "all"), None)
133
+ if all_flow:
134
+ lines += ["## Review flow", "",
135
+ f"In the typical repo, a pull request that gets reviewed waits {_hrs(all_flow['first_review_hours_p50'])} for its "
136
+ f"first review by a maintainer other than its author. Merged pull requests are merged a median "
137
+ f"{_hrs(all_flow['merge_hours_p50'])} after opening"
138
+ + (" (this counts PRs merged with no review, and comes from a different set of repos)"
139
+ if all_flow["merge_hours_p50"] not in ("", None) and all_flow["first_review_hours_p50"] not in ("", None)
140
+ and float(all_flow["merge_hours_p50"]) < float(all_flow["first_review_hours_p50"]) else "")
141
+ + f". The typical repo merges {_pct(all_flow['outsider_merge_rate_p50'])} of outside contributors' decided PRs "
142
+ f"({int(all_flow['repos']):,} repos).", ""]
143
+ cols = [("First review (reviewed PRs)", "first_review_hours_p50", _hrs), ("To merge (all merged PRs)", "merge_hours_p50", _hrs),
144
+ ("Outside PRs merged", "outsider_merge_rate_p50", _pct)]
145
+ lines += _table(rows["flow"], "language", cols) + [""] + _table(rows["flow"], "size", cols) + [""]
146
+ agents = (flow.get("agent_prs") or {}).get("all|all")
147
+ if agents:
148
+ lines += [f"**Coding agents:** in the typical repo that gets both, {_pct(agents['agent']['merged'])} of pull requests "
149
+ f"opened by coding agents (often started by the repo's own maintainers) were merged, against {_pct(agents['outsider']['merged'])} of people's outside PRs "
150
+ f"({agents['repos_with_agent_prs']:,} repos; {agents['agent']['decided']:,} agent PRs and "
151
+ f"{agents['outsider']['decided']:,} outside PRs decided in the last 180 days).", ""]
152
+ bt = flow.get("odds_backtest") or {}
153
+ if (bt.get("mae") or {}).get("calibrated") is not None:
154
+ lines += [f"**Can a repo's record predict a new outside PR?** A repo's past merge rate for outside PRs runs high for "
155
+ f"its next month's outside PRs, because many stay open. Corrected by what happened on other repos with a "
156
+ f"similar record, it was off by {bt['mae']['calibrated'] * 100:.0f} points on average ({bt['repos']:,} repos, "
157
+ f"{bt['prs']:,} PRs; checked on repos the correction was not built from); the raw rate was off by "
158
+ f"{bt['mae']['raw'] * 100:.0f} points and the typical repo's rate alone by {bt['mae']['typical_only'] * 100:.0f}.", ""]
159
+ all_com = next((r for r in rows.get("community", []) if r["language"] == "all" and r["size"] == "all"), None)
160
+ if all_com or newcomers:
161
+ lines += ["## Outside contributors and newcomers", ""]
162
+ if all_com:
163
+ lines += [f"The typical repo first answers an outside contributor's PR in {_hrs(all_com['first_answer_hours_p50'])}; "
164
+ f"it answers {_pct(all_com['answered_1d_share_p50'])} of them within a day ({int(all_com['repos']):,} repos).", ""]
165
+ if newcomers.get("by_answer"):
166
+ lines += [f"Of {newcomers['newcomers']:,} newcomers (a first PR in a repo), the share who came back with new work "
167
+ f"within {newcomers.get('return_days', 90)} days, by how long their first PR waited for an answer:", "",
168
+ "| First answer | Newcomers | Came back | First PR merged |", "|---|---|---|---|"]
169
+ lines += [f"| {b['bucket']} | {b['newcomers']:,} | {_pct(b['returned'])} | {_pct(b['merged'])} |" for b in newcomers["by_answer"]]
170
+ lines += ["", "This is a correlation, not a promise.", ""]
171
+ all_del = next((r for r in rows.get("delivery", []) if r["language"] == "all" and r["size"] == "all"), None)
172
+ if all_del:
173
+ def mid(m):
174
+ # One decimal: P75s of a few hours lose their meaning at whole hours.
175
+ value, low, high = (all_del.get(m + k) for k in ("_p50", "_p50_low", "_p50_high"))
176
+ if value in ("", None):
177
+ return "-"
178
+ if float(value) >= 48:
179
+ return _hrs(value)
180
+ span = f" ({float(low):.1f}-{float(high):.1f})" if low not in ("", None) and high not in ("", None) else ""
181
+ return f"{float(value):.1f} h{span}"
182
+ dl = data.get("delivery") or {}
183
+ lines += ["## Delivery: pickup and review against LinearB's bands", "",
184
+ f"The typical repo's P75 pickup (opening to the first maintainer review) is {mid('pickup_p75_hours')}, its P75 "
185
+ f"review (first review to merge) {mid('review_p75_hours')} and its P75 from opening to merge "
186
+ f"{mid('cycle_to_merge_p75_hours')} ({int(all_del['repos']):,} repos; ranges are 95% bootstrap ranges of the "
187
+ f"median). Inside LinearB's good band (pickup up to 4 h, review up to 14 h): "
188
+ f"{_pct(all_del.get('pickup_p75_hours_linearb_good'))} of repos for pickup and "
189
+ f"{_pct(all_del.get('review_p75_hours_linearb_good'))} for review. {dl.get('bands_note', '')}.", ""]
190
+ cols = [("Pickup P75 (typical)", "pickup_p75_hours_p50", _hrs1), ("Review P75 (typical)", "review_p75_hours_p50", _hrs1),
191
+ ("Pickup inside LinearB's good band", "pickup_p75_hours_linearb_good", _pct)]
192
+ lines += _table(rows["delivery"], "language", cols) + [""] + _table(rows["delivery"], "size", cols) + [""]
193
+ dep = data.get("deploys") or {}
194
+ top = (dep.get("groups") or {}).get("all|all") or {}
195
+ if top.get("sources"):
196
+ src, tiers = top["sources"], top.get("tiers") or {}
197
+ lines += ["## Shipping: how often public repos release", "",
198
+ f"Of {int(top['repos']):,} sampled repos, {_pct(src.get('releases'))} show their shipping through GitHub "
199
+ f"releases, {_pct(src.get('deployments api'))} through production deployments, and {_pct(src.get('none'))} "
200
+ "through neither (they ship from another repo or a service GitHub can't see)."
201
+ + (f" Among those that show it, by DORA 2023's frequency tiers: {_pct(tiers.get('elite'))} elite, "
202
+ f"{_pct(tiers.get('high'))} high, {_pct(tiers.get('medium'))} medium, {_pct(tiers.get('low'))} low."
203
+ if tiers else ""), ""]
204
+ lines += ["## Method and files", ""]
205
+ for name, d in data.items():
206
+ if d:
207
+ lines.append(f"- **{name}.csv** ({d.get('repos', 0):,} repos, built {(d.get('built_at') or 'date unknown')[:10]}): "
208
+ f"{d.get('definition', '')}")
209
+ if d.get("population") or d.get("gaps"):
210
+ lines.append(f" Who is counted: {d.get('population') or '-'}. Not covered: {d.get('gaps') or '-'}.")
211
+ lines += ["", "Each CSV row is a group of repos (language and star range, or `all`); for each measure it gives the number of "
212
+ "repos measured, the 10th, 25th, 50th, 75th and 90th percentiles across them, and a 95% range for the median "
213
+ "(the 50th) from 400 bootstrap resamples of the group's repos with a fixed seed, so a rebuild gives the same "
214
+ "numbers. A public crawl is not every team: private and enterprise repos are not in it. To recompute a number:",
215
+ "", RECOMPUTE_SQL, "", RECOMPUTE, ""]
216
+ (out / "report.md").write_text("\n".join(lines), encoding="utf-8")
217
+ return out / "report.md"
218
+
219
+
220
+ def main(argv: list[str] | None = None) -> int:
221
+ parser = argparse.ArgumentParser(prog="python -m mergescout.bench.report", description="Build the public report from benchmark files.")
222
+ parser.add_argument("bench", help="folder with the *-benchmarks.json files")
223
+ parser.add_argument("out", help="folder for report.md and the CSV files")
224
+ args = parser.parse_args(argv)
225
+ print(build_report(Path(args.bench), Path(args.out)))
226
+ return 0
227
+
228
+
229
+ if __name__ == "__main__":
230
+ raise SystemExit(main())
@@ -0,0 +1,112 @@
1
+ """Peer groups from opt-in shared figures: issues labelled "benchmark" on this repo, each carrying one repo's rounded CI
2
+ figures (filed from a CI run summary through the "benchmark" issue form); closing an issue withdraws it. Groups are published only with 10+ repos.
3
+
4
+ GH_TOKEN=... python -m mergescout.bench.shared --repo owner/name --out out/shared-benchmarks.json
5
+ """
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import statistics
11
+ import sys
12
+ from datetime import datetime, timezone
13
+
14
+ import httpx
15
+
16
+ MIN_GROUP = 10
17
+ RANKS = (0.10, 0.25, 0.50, 0.75, 0.90)
18
+ BLOCK = re.compile(r"```json\s*(\{.*?\})\s*```", re.S)
19
+ SHARES = ("first_try_share", "fail_share", "rerun_share")
20
+ NUMBERS = ("pushes", "median_s", "p90_s")
21
+ KEYS = {"schema", "kind", "id", "month", "language", "tier", "pushes", "median_s", "p90_s", *SHARES}
22
+
23
+
24
+ def read_issue(issue: dict) -> dict | None:
25
+ """The figures in one issue's body, or None when they're missing or don't look like ours."""
26
+ found = BLOCK.search(issue.get("body") or "")
27
+ try:
28
+ doc = json.loads(found[1]) if found else None
29
+ except ValueError:
30
+ return None
31
+ if not isinstance(doc, dict) or not KEYS <= set(doc) or set(doc) - KEYS - {"repo"}:
32
+ return None
33
+ ok = (doc["schema"] == 1 and doc["kind"] == "ci-benchmark" and re.fullmatch(r"[0-9a-f]{16}", str(doc["id"]))
34
+ and re.fullmatch(r"20\d{2}-(0[1-9]|1[0-2])", str(doc["month"])) and isinstance(doc["language"], str) and len(doc["language"]) < 40
35
+ and doc["tier"] in ("100-1k", "1k-10k", "10k+")
36
+ and all(isinstance(doc[k], (int, float)) and not isinstance(doc[k], bool) and 0 <= doc[k] <= 1 for k in SHARES)
37
+ and all(isinstance(doc[k], int) and not isinstance(doc[k], bool) and 0 <= doc[k] < 10**7 for k in NUMBERS) and doc["pushes"] > 0)
38
+ return doc if ok else None
39
+
40
+
41
+ def _q(values: list[float]) -> dict:
42
+ s = sorted(values)
43
+ cuts = statistics.quantiles(s, n=100, method="inclusive") if len(s) > 1 else s * 99
44
+ return {f"p{int(r * 100)}": round(cuts[int(r * 100) - 1], 4) for r in RANKS}
45
+
46
+
47
+ def build(docs: list[dict], now: datetime | None = None) -> dict:
48
+ latest = {}
49
+ for d in docs:
50
+ if d and (d["id"] not in latest or d["month"] > latest[d["id"]]["month"]):
51
+ latest[d["id"]] = d
52
+ groups = {}
53
+ for d in latest.values():
54
+ for key in (f"{d['language']}|{d['tier']}", f"{d['language']}|all", f"all|{d['tier']}", "all|all"):
55
+ groups.setdefault(key, []).append(d)
56
+ now = now or datetime.now(timezone.utc)
57
+ return {"built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "private": True, "repos": len(latest),
58
+ "about": "Repos that chose to share rounded CI figures (public and private), latest month each.",
59
+ "groups": {k: {"repos": len(g), "median_s": _q([d["median_s"] for d in g]),
60
+ "first_try_share": _q([d["first_try_share"] for d in g]),
61
+ "fail_share": _q([d["fail_share"] for d in g])}
62
+ for k, g in sorted(groups.items()) if len(g) >= MIN_GROUP}}
63
+
64
+
65
+ def one_per_account(issues: list[dict], now: datetime | None = None) -> list[dict]:
66
+ """Each GitHub account's newest valid figures only: filed figures can't be verified, so one account can't fill a group.
67
+ An id belongs to the first account that filed it, so nobody can replace another repo's figures; future months are dropped."""
68
+ month = f"{now or datetime.now(timezone.utc):%Y-%m}"
69
+ mine, owner = {}, {}
70
+ for i in sorted(issues, key=lambda i: i.get("created_at") or ""):
71
+ doc, who = read_issue(i), ((i.get("user") or {}).get("login") or "").lower()
72
+ if not doc or not who or doc["month"] > month or owner.setdefault(doc["id"], who) != who:
73
+ continue
74
+ mine[who] = doc
75
+ return list(mine.values())
76
+
77
+
78
+ def fetch(repo: str, token: str, client: httpx.Client | None = None) -> list[dict]:
79
+ client = client or httpx.Client(timeout=60)
80
+ out = []
81
+ for page in range(1, 51):
82
+ got = client.get(f"https://api.github.com/repos/{repo}/issues", headers={"Authorization": f"Bearer {token}"},
83
+ params={"labels": "benchmark", "state": "open", "per_page": 100, "page": page})
84
+ got.raise_for_status()
85
+ got = got.json()
86
+ out += [i for i in got if "pull_request" not in i]
87
+ if len(got) < 100:
88
+ break
89
+ return out
90
+
91
+
92
+ def main(argv=None) -> int:
93
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
94
+ ap.add_argument("--repo", required=True, action="append", help="repeatable: every repo whose issues hold figures")
95
+ ap.add_argument("--out", required=True)
96
+ args = ap.parse_args(argv)
97
+ issues = []
98
+ for repo in args.repo:
99
+ # A repo the token can't read yet (still private, or gone) is skipped; the others still count.
100
+ try:
101
+ issues += fetch(repo, os.environ["GH_TOKEN"])
102
+ except httpx.HTTPError as exc:
103
+ print(f"{repo}: skipped ({exc.__class__.__name__})")
104
+ out = build(one_per_account(issues))
105
+ with open(args.out, "w", encoding="utf-8") as f:
106
+ json.dump(out, f, indent=1)
107
+ print(f"{len(issues)} issues, {out['repos']} repos, {len(out['groups'])} groups published")
108
+ return 0
109
+
110
+
111
+ if __name__ == "__main__":
112
+ sys.exit(main())
@@ -0,0 +1,57 @@
1
+ """Star growth of crawled repos from GitHub's weekly star history (no stargazer identities), as percentiles by language
2
+ and size: the peer line next to a repo's own star trend."""
3
+ from datetime import datetime
4
+
5
+ from mergescout.bench.ci import TokenPool, _summary, tier
6
+
7
+ WEEKS = 12
8
+ MIN_GROUP = 20
9
+ DEFINITION = ("per repo: stars gained in the last 12 complete weeks, and that gain as a share of the stars it had "
10
+ "before them, from GitHub's weekly star history")
11
+
12
+
13
+ def star_stats(rows: list, stars: int | None, now: datetime) -> dict | None:
14
+ """Gain over the last 12 complete weeks; the running week is left out, and younger repos get no figure."""
15
+ rows = sorted((r for r in rows or [] if isinstance(r, dict) and isinstance(r.get("week"), (int, float))),
16
+ key=lambda r: -r["week"])
17
+ done = [r for r in rows if r["week"] + 7 * 86400 <= now.timestamp()]
18
+ if len(done) < WEEKS:
19
+ return None
20
+ gained = sum(int(r.get("total") or 0) for r in done[:WEEKS])
21
+ running = sum(int(r.get("total") or 0) for r in rows if r not in done)
22
+ before = max((stars or 0) - gained - running, 1)
23
+ return {"gained_12w": gained, "growth_12w": round(gained / before, 4)}
24
+
25
+
26
+ def read_stars(pool: TokenPool, repo: dict, now: datetime) -> dict | None:
27
+ # One request: the first page holds the newest 30 weeks.
28
+ resp = pool.get(f"/repos/{repo['name']}/stargazers/history", {})
29
+ if resp is None:
30
+ raise RuntimeError("GitHub gave no answer after retries")
31
+ if resp.status_code >= 400:
32
+ return None
33
+ stats = star_stats(resp.json(), repo.get("stars"), now)
34
+ return {"repo": repo["name"], "language": repo.get("language"), "stars": repo.get("stars"), **stats} if stats else None
35
+
36
+
37
+ def aggregate_stars(records: list[dict], now: datetime, top_languages: int = 25) -> dict:
38
+ """Percentiles by language and size; a group is published only with MIN_GROUP repos."""
39
+ latest = {}
40
+ for r in sorted(records, key=lambda r: r.get("day") or ""):
41
+ latest[r["repo"].lower()] = r
42
+ recs = list(latest.values())
43
+ counts = {}
44
+ for r in recs:
45
+ counts[r.get("language") or "Other"] = counts.get(r.get("language") or "Other", 0) + 1
46
+ langs = {name for name, _ in sorted(counts.items(), key=lambda kv: -kv[1])[:top_languages]}
47
+ groups = {}
48
+ for r in recs:
49
+ lang = r.get("language") if r.get("language") in langs else "Other"
50
+ for key in (f"{lang}|{tier(r.get('stars'))}", f"{lang}|all", f"all|{tier(r.get('stars'))}", "all|all"):
51
+ groups.setdefault(key, []).append(r)
52
+ out = {key: {"repos": len(items), "gained_12w": _summary([r["gained_12w"] for r in items]),
53
+ "growth_12w": _summary([r["growth_12w"] for r in items])}
54
+ for key, items in groups.items() if len(items) >= MIN_GROUP}
55
+ return {"version": 1, "built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "repos": len(recs), "definition": DEFINITION,
56
+ "population": "public GitHub repos with 100+ stars that pushed in the last 30 days (the CI sampler's daily slice)",
57
+ "gaps": "stars only: no forks, downloads or traffic; no private repos", "groups": out}
mergescout/cli.py ADDED
@@ -0,0 +1,89 @@
1
+ import argparse
2
+ import json
3
+ import logging
4
+ import sys
5
+
6
+ import httpx
7
+
8
+ from mergescout.github.releases import ReleaseError
9
+ from mergescout.mcp_server import App, NoSnapshot, build_server, live_status, plain
10
+ from mergescout.tool.live import LiveError
11
+ from mergescout.tool.action_pack import build_action_pack
12
+ from mergescout.tool.refs import parse_ref
13
+
14
+
15
+ def _print(data) -> None:
16
+ print(json.dumps(data, indent=1, ensure_ascii=False))
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ argv = list(sys.argv[1:] if argv is None else argv) or ["serve"]
21
+ parser = argparse.ArgumentParser(prog="mergescout", description="Find open-source issues you can get merged.",
22
+ epilog="Also: `stackvitals contribute brief owner/repo`, how changes get merged in a repo.")
23
+ sub = parser.add_subparsers(dest="cmd", required=True)
24
+ sub.add_parser("serve", help="Run the MCP server on stdio (what Claude Code and Cursor start)")
25
+ u = sub.add_parser("update", help="Download the newest snapshot")
26
+ u.add_argument("--force", action="store_true")
27
+ f = sub.add_parser("find", help="Best free issues")
28
+ f.add_argument("--lang")
29
+ f.add_argument("--topic")
30
+ f.add_argument("--beginner", action="store_true")
31
+ f.add_argument("--no-walls", action="store_true")
32
+ f.add_argument("--limit", type=int, default=10)
33
+ f.add_argument("--json", action="store_true")
34
+ for name in ("check", "explain", "pack", "health", "track", "untrack"):
35
+ sub.add_parser(name).add_argument("ref")
36
+ o = sub.add_parser("outcome")
37
+ o.add_argument("ref")
38
+ o.add_argument("outcome")
39
+ o.add_argument("--note", default="")
40
+ sub.add_parser("tracked")
41
+ sub.add_parser("stats")
42
+ args = parser.parse_args(argv)
43
+ if args.cmd == "serve":
44
+ # Request lines from the HTTP library would only clutter the AI client's log.
45
+ logging.getLogger("httpx").setLevel(logging.WARNING)
46
+ build_server(App()).run("stdio")
47
+ return 0
48
+ app = App(log=lambda msg: print(msg, file=sys.stderr))
49
+ try:
50
+ if args.cmd == "update":
51
+ print(app.store.update(force=args.force))
52
+ elif args.cmd == "find":
53
+ out = app.snapshot().find_issues(args.lang, args.topic, args.beginner, 0.0, not args.no_walls, args.limit)
54
+ if args.json:
55
+ _print(out)
56
+ else:
57
+ print(f"snapshot {out['snapshot']} ({out['note']})")
58
+ for r in out["results"]:
59
+ print(f"{r['score']:.2f} {r['issue']} {r['title']}\n {r['outsider_merge']}; {r['review']}"
60
+ + (f"; walls: {', '.join(r['walls'])}" if r["walls"] else ""))
61
+ elif args.cmd == "check":
62
+ _print(live_status(app, parse_ref(args.ref)))
63
+ elif args.cmd == "explain":
64
+ ref = parse_ref(args.ref)
65
+ _print(app.snapshot().explain(ref.repo, ref.number))
66
+ elif args.cmd == "pack":
67
+ _print(build_action_pack(app.snapshot(), parse_ref(args.ref)))
68
+ elif args.cmd == "health":
69
+ _print(app.snapshot().repo_health(parse_ref(args.ref).repo, detail=True))
70
+ elif args.cmd == "track":
71
+ ref = parse_ref(args.ref)
72
+ _print(app.tracker.track(ref.key, app.live.issue_status(ref.owner, ref.name, ref.number)))
73
+ elif args.cmd == "untrack":
74
+ app.tracker.untrack(parse_ref(args.ref).key)
75
+ _print({"tracked": sorted(app.tracker.tracked())})
76
+ elif args.cmd == "tracked":
77
+ _print({"tracked": app.tracker.tracked(), "changes": app.tracker.updates(app.live)})
78
+ elif args.cmd == "outcome":
79
+ _print(app.tracker.report_outcome(parse_ref(args.ref).key, args.outcome, args.note))
80
+ elif args.cmd == "stats":
81
+ _print(app.snapshot().stats())
82
+ except (NoSnapshot, ValueError, LiveError, httpx.HTTPError, ReleaseError) as exc:
83
+ print(plain(exc), file=sys.stderr)
84
+ return 1
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ raise SystemExit(main())
mergescout/config.py ADDED
@@ -0,0 +1,35 @@
1
+ import os
2
+ import tempfile
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ def load_dotenv(path: Path) -> None:
8
+ # Read KEY=VALUE lines into the environment without overriding values already set.
9
+ if not path.exists():
10
+ return
11
+ for raw in path.read_text(encoding="utf-8").splitlines():
12
+ line = raw.strip()
13
+ if not line or line.startswith("#") or "=" not in line:
14
+ continue
15
+ key, value = line.split("=", 1)
16
+ os.environ.setdefault(key.strip(), value.strip().strip("\"").strip("'"))
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Config:
21
+ github_token: str | None
22
+ data_dir: Path
23
+
24
+ def require_token(self) -> str:
25
+ if not self.github_token:
26
+ raise RuntimeError("GITHUB_TOKEN is not set. Add it to .env (see .env.example).")
27
+ return self.github_token
28
+
29
+
30
+ def load_config(env_file: Path | None = Path(".env")) -> Config:
31
+ if env_file is not None:
32
+ load_dotenv(env_file)
33
+ data_dir = Path(os.environ.get("MS_DATA_DIR") or Path(tempfile.gettempdir()) / "mergescout")
34
+ data_dir.mkdir(parents=True, exist_ok=True)
35
+ return Config(github_token=os.environ.get("GITHUB_TOKEN") or None, data_dir=data_dir)
File without changes
@@ -0,0 +1,97 @@
1
+ import argparse
2
+ import os
3
+ import sqlite3
4
+ import sys
5
+ import time
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from mergescout.config import load_config
10
+ from mergescout.crawl.chain import next_step, write_last_run
11
+ from mergescout.crawl.runner import run_crawl
12
+ from mergescout.crawl.seed import read_watch, seed_queue
13
+ from mergescout.crawl.state import CrawlState
14
+ from mergescout.github.graphql import GitHubGraphQL
15
+ from mergescout.github.identities import identities_from_env
16
+
17
+
18
+ def main(argv: list[str] | None = None) -> int:
19
+ parser = argparse.ArgumentParser(prog="python -m mergescout.crawl", description="Crawl repos at full depth.")
20
+ sub = parser.add_subparsers(dest="cmd", required=True)
21
+ run = sub.add_parser("run", help="Crawl until the time or points budget runs out (safe to stop and re-run)")
22
+ run.add_argument("--minutes", type=float, default=50)
23
+ run.add_argument("--limit", type=int, default=None)
24
+ run.add_argument("--in-flight", type=int, default=12)
25
+ run.add_argument("--reserve", type=int, default=300, help="Stop when fewer points than this are left")
26
+ run.add_argument("--hot-days", type=int, default=1, help="Refresh repos with beginner issues after this many days")
27
+ run.add_argument("--cold-days", type=int, default=7, help="Refresh other repos after this many days")
28
+ run.add_argument("--max-wait", type=float, default=120,
29
+ help="Longest wait for GitHub's rate limit; also capped by the time left in the run")
30
+ run.add_argument("--grace", type=float, default=8,
31
+ help="Minutes after --minutes that running work may still spend sitting out a wait")
32
+ run.add_argument("--full-every-days", type=int, default=30, help="Redo a full crawl this often; refreshes in between")
33
+ sub.add_parser("status", help="Show the queue and the last runs")
34
+ seed = sub.add_parser("seed", help="Queue active repos from the sweep, beginner-friendly repos first")
35
+ seed.add_argument("--min-stars", type=int, default=100)
36
+ seed.add_argument("--active-days", type=int, default=90)
37
+ seed.add_argument("--watch", type=Path, default=None, help="File of owner/name lines to crawl first")
38
+ sub.add_parser("requeue-failed", help="Give failed repos another try (up to 3 attempts)")
39
+ bk = sub.add_parser("backup", help="Copy the state file safely, even while it is in use")
40
+ bk.add_argument("dest")
41
+ nx = sub.add_parser("next", help="Print which workflow the relay starts next: discover, crawl, crawl-later or none")
42
+ nx.add_argument("--after-discover", action="store_true")
43
+ args = parser.parse_args(argv)
44
+
45
+ cfg = load_config()
46
+ state = CrawlState(cfg.data_dir / "crawl.sqlite")
47
+ now = datetime.now(timezone.utc)
48
+ if args.cmd == "status":
49
+ print(f"repos={state.counts()}")
50
+ for r in state.db.execute("SELECT run_id, started_at, finished_at, repos, points, reason FROM runs "
51
+ "ORDER BY started_at DESC LIMIT 5"):
52
+ print("run " + " ".join(str(v) for v in tuple(r)))
53
+ tracked = state.db.execute("SELECT COUNT(*) FROM issue_state").fetchone()[0]
54
+ print(f"tracked issues={tracked:,}")
55
+ return 0
56
+ if args.cmd == "backup":
57
+ dest = sqlite3.connect(args.dest)
58
+ with dest:
59
+ state.db.backup(dest)
60
+ dest.close()
61
+ return 0
62
+ if args.cmd == "next":
63
+ print(next_step(state, cfg.data_dir, now, after_discover=args.after_discover))
64
+ return 0
65
+ if args.cmd == "requeue-failed":
66
+ print(f"requeued {state.requeue_failed(3)}")
67
+ return 0
68
+ if args.cmd == "seed":
69
+ seed_queue(state, cfg.data_dir, now, min_stars=args.min_stars, active_days=args.active_days,
70
+ watch=read_watch(args.watch))
71
+ return 0
72
+ try:
73
+ token = cfg.require_token()
74
+ except RuntimeError as exc:
75
+ print(str(exc), file=sys.stderr)
76
+ return 1
77
+ run_id = os.environ.get("MS_RUN_ID") or now.strftime("%Y%m%dT%H%M%SZ")
78
+ print(f"requeued stale={state.requeue_stale(120)} failed={state.requeue_failed(3)}", flush=True)
79
+ # Any wait GitHub asks for is sat out if it still fits in this run; otherwise that identity steps aside.
80
+ end = time.monotonic() + (args.minutes + args.grace) * 60
81
+
82
+ def allowed_wait() -> float:
83
+ return max(0.0, min(args.max_wait, end - time.monotonic()))
84
+ # Personal token plus, when set, the GitHub App and workflow tokens: each with its own limits and wait.
85
+ identities = identities_from_env(token, allowed_wait, client=GitHubGraphQL)
86
+ out = run_crawl(state, identities, cfg.data_dir, run_id,
87
+ minutes=args.minutes, in_flight=args.in_flight, reserve=args.reserve, limit=args.limit,
88
+ hot_days=args.hot_days, cold_days=args.cold_days, full_every_days=args.full_every_days,
89
+ log=lambda msg: print(msg, flush=True))
90
+ for i in identities:
91
+ print(f"github {i.name}: {i.gate.summary()}", flush=True)
92
+ write_last_run(cfg.data_dir, out)
93
+ return 1 if out["repos"] == 0 and out["failed"] else 0
94
+
95
+
96
+ if __name__ == "__main__":
97
+ raise SystemExit(main())