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,187 @@
1
+ """Where the work goes: each merged pull request sorted into feature, fix, maintenance, security or docs work from the
2
+ repo's own label rules, its Jira or Linear issue's type, common labels, its changelog fragment, its title (a Conventional
3
+ Commit or component prefix, or the leading verb) or the files it touched; the mix per month, with how each was decided.
4
+ People's and agents' work only: update bots are counted apart."""
5
+ import fnmatch
6
+ import re
7
+ from collections import Counter
8
+ from datetime import datetime
9
+
10
+ from stackvitals.core.actors import kind_of
11
+ from stackvitals.core.config import INVESTMENT as CATEGORIES
12
+
13
+ OTHER = "other"
14
+ # Common label names (the part after "type:", "kind/" and the like), checked in this order: security work wins.
15
+ LABELS = (("security", ("security", "vulnerability", "cve")),
16
+ ("fix", ("bug", "fix", "bugfix", "regression", "defect", "hotfix")),
17
+ ("feature", ("feature", "enhancement", "feat", "new feature", "feature request")),
18
+ ("docs", ("documentation", "docs", "doc")),
19
+ ("maintenance", ("chore", "refactor", "refactoring", "dependencies", "deps", "tech debt", "tech-debt", "ci",
20
+ "build", "maintenance", "performance", "perf", "cleanup", "test", "tests", "infra")))
21
+ TITLES = {"feat": "feature", "feature": "feature", "fix": "fix", "bugfix": "fix", "hotfix": "fix", "revert": "fix", "bug": "fix",
22
+ "docs": "docs", "doc": "docs", "documentation": "docs", "security": "security", "sec": "security",
23
+ "chore": "maintenance", "refactor": "maintenance", "ci": "maintenance", "build": "maintenance", "test": "maintenance",
24
+ "tests": "maintenance", "testing": "maintenance", "perf": "maintenance", "style": "maintenance", "deps": "maintenance",
25
+ "mnt": "maintenance", "maint": "maintenance", "bench": "maintenance", "release": "maintenance"}
26
+ CONVENTIONAL = re.compile(r"^\s*([A-Za-z]+)(?:\([^)]*\))?!?:")
27
+ # Changelog fragments (towncrier and the like) name their kind: news/1234.bugfix.rst, changelog/12.feature.md, CHANGES/9.bugfix.
28
+ FRAGMENT = re.compile(r"(?:^|/)(?:news|changes|changelog|changelog\.d|newsfragments)/[^/]+?\.([A-Za-z]+)(?:\.(?:rst|md|txt))?$",
29
+ re.I)
30
+ FRAGMENTS = {"security": "security", "bugfix": "fix", "bugfixes": "fix", "bug": "fix", "fix": "fix", "fixed": "fix",
31
+ "feature": "feature", "features": "feature", "improvement": "feature", "enhancement": "feature", "added": "feature",
32
+ "removal": "maintenance", "deprecation": "maintenance", "breaking": "maintenance", "misc": "maintenance",
33
+ "trivial": "maintenance", "vendor": "maintenance", "process": "maintenance", "packaging": "maintenance",
34
+ "contrib": "maintenance", "internal": "maintenance", "performance": "maintenance", "doc": "docs", "docs": "docs",
35
+ "documentation": "docs"}
36
+ # A title's leading verb, once backport tags, a component prefix and a scope are set aside ("[3.14] dialects: (llvm) add").
37
+ VERBS = {**dict.fromkeys(("fix", "fixes", "fixed", "fixing", "revert", "resolve", "resolves", "correct", "repair", "prevent",
38
+ "avoid", "handle", "restore", "preserve", "don't"), "fix"),
39
+ **dict.fromkeys(("add", "adds", "added", "adding", "implement", "implements", "introduce", "support", "allow",
40
+ "enable", "expose", "new", "create", "provide", "feat", "feature"), "feature"),
41
+ **dict.fromkeys(("refactor", "remove", "removes", "drop", "clean", "cleanup", "bump", "upgrade", "deprecate",
42
+ "simplify", "move", "rename", "replace", "migrate", "speed", "optimize", "optimise", "vendor", "pin",
43
+ "unpin", "release", "prepare", "tidy", "reorganize", "restructure", "test", "tests", "skip", "lint",
44
+ "use", "rework", "extract", "split", "switch"), "maintenance"),
45
+ **dict.fromkeys(("document", "documents", "docs", "doc", "typo", "readme", "clarify"), "docs")}
46
+ DOCS = ("docs/*", "doc/*", "*.md", "*.rst", "*.adoc", "mkdocs.yml", "LICENSE*")
47
+ TESTS = ("tests/*", "test/*", "testing/*", "__tests__/*", "*_test.*", "test_*", "*.spec.*", "*.test.*", "conftest.py")
48
+ UPKEEP = (".github/*", "*.lock", "*lock.json", "*lock.yaml", "go.sum", "requirements*.txt", ".pre-commit-config.yaml",
49
+ "Dockerfile*", ".gitignore", "renovate.json", ".editorconfig")
50
+ ORDER = ("security", "fix", "feature", "maintenance", "docs") # when a PR carries fragments of several kinds
51
+ RELEASE = re.compile(r"^\s*(?:(?:prepare\s+)?release\b(?!s)|bump\s+version\b|v?\d+\.\d+(?:\.\d+)?\s*$)", re.I)
52
+ TRACKER = {"BUG": "fix", "INCIDENT": "fix", "REQUIREMENT": "feature"}
53
+ MONTHS = 6
54
+
55
+
56
+ def _key(label: str) -> str:
57
+ # "type: bug", "kind/bug", "T-bug", "🐛 bug" all read as "bug".
58
+ last = re.split(r"[:/]", label.lower())[-1]
59
+ return re.sub(r"^[^a-z0-9]+", "", last).removeprefix("t-").strip(" -_")
60
+
61
+
62
+ def _all(paths: list[str], patterns: tuple) -> bool:
63
+ return bool(paths) and all(any(fnmatch.fnmatch(p, g) or fnmatch.fnmatch(p.rsplit("/", 1)[-1], g) for g in patterns)
64
+ for p in paths)
65
+
66
+
67
+ def _verb(title: str) -> str | None:
68
+ t = re.sub(r"^\s*(\[[^\]]*\]\s*)+", "", title)
69
+ t = re.sub(r"^\s*[\w./-]+:\s*", "", t)
70
+ t = re.sub(r"^\(([^)]*)\)\s*", "", t)
71
+ word = re.match(r"[A-Za-z]+(?:'t)?", t)
72
+ return word.group(0).lower() if word else None
73
+
74
+
75
+ def classify(pr: dict, rules: dict | None = None, tracker_type: str | None = None) -> tuple[str, str]:
76
+ """(category, how it was decided) for one pull request."""
77
+ labels = pr.get("lb") or []
78
+ low = {x.lower(): x for x in labels}
79
+ for cat in CATEGORIES:
80
+ for name in (rules or {}).get(cat) or []:
81
+ if name.lower() in low:
82
+ return cat, f"label {low[name.lower()]} (this repo's rule)"
83
+ keys = {_key(x): x for x in labels}
84
+ if any(k in keys for k in LABELS[0][1]):
85
+ return "security", f"label {next(keys[k] for k in LABELS[0][1] if k in keys)}"
86
+ if tracker_type in TRACKER:
87
+ return TRACKER[tracker_type], f"issue type {tracker_type.lower()}"
88
+ for cat, names in LABELS[1:]:
89
+ hit = next((keys[k] for k in names if k in keys), None)
90
+ if hit:
91
+ return cat, f"label {hit}"
92
+ title = pr.get("ti") or ""
93
+ # A release PR gathers every changelog fragment, so its title decides (pip's "Release 26.2").
94
+ if RELEASE.match(title):
95
+ return "maintenance", "title release"
96
+ found = CONVENTIONAL.match(title)
97
+ if found and found.group(1).lower() in TITLES:
98
+ return TITLES[found.group(1).lower()], f"title {found.group(1).lower()}:"
99
+ paths = pr.get("paths") or []
100
+ fragments = [k.lower() for p in paths if (m := FRAGMENT.search(p)) and (k := m.group(1)).lower() in FRAGMENTS]
101
+ kinds = {FRAGMENTS[k] for k in fragments}
102
+ if len(fragments) >= 3 and len(kinds) >= 2:
103
+ return "maintenance", "changelog fragments of several kinds" # release notes gathered or edited
104
+ if fragments:
105
+ kind = min(fragments, key=lambda k: ORDER.index(FRAGMENTS[k]))
106
+ return FRAGMENTS[kind], f"changelog fragment {kind}"
107
+ if _all(paths, DOCS):
108
+ return "docs", "only docs files"
109
+ if _all(paths, TESTS):
110
+ return "maintenance", "only test files"
111
+ if _all(paths, UPKEEP):
112
+ return "maintenance", "only CI or dependency files"
113
+ if "[NFC]" in title:
114
+ return "maintenance", "title [NFC]"
115
+ verb = _verb(title)
116
+ if verb in VERBS:
117
+ return VERBS[verb], f"title verb {verb}"
118
+ if re.search(r"\b(fix|fixes|fixed|bug|crash|regression)\b", title, re.I):
119
+ return "fix", "title mentions a fix"
120
+ return OTHER, "no label, issue type, changelog fragment, title or file pattern"
121
+
122
+
123
+ def mix(prs: list[dict], now: datetime, rules: dict | None = None, tracker_types: dict | None = None,
124
+ months: int = MONTHS, base: str | None = None) -> dict | None:
125
+ """Merged PRs into the default branch (`base`; backports to release branches would count the work twice) of the
126
+ last `months` calendar months (this one so far) by category and month, how each category was decided, and the update
127
+ bots' PRs counted apart; None without a merged PR."""
128
+ first = (now.year * 12 + now.month - 1) - (months - 1)
129
+ since = f"{first // 12:04d}-{first % 12 + 1:02d}"
130
+ merged = [p for p in prs if p.get("mg") and p["mg"][:7] >= since and (base is None or p.get("base") in (None, base))]
131
+ work = [p for p in merged if kind_of(p.get("au"), p.get("at")) in ("person", "agent")]
132
+ if not work:
133
+ return None
134
+ by_month, total, how, examples = {}, Counter(), Counter(), {}
135
+ for p in sorted(work, key=lambda p: p["mg"]):
136
+ cat, why = classify(p, rules, (tracker_types or {}).get(p["n"]))
137
+ by_month.setdefault(p["mg"][:7], Counter())[cat] += 1
138
+ total[cat] += 1
139
+ how[why.split(" ")[0] if cat != OTHER else OTHER] += 1
140
+ examples.setdefault(cat, []).append(p["n"])
141
+ order = (*CATEGORIES, OTHER)
142
+ return {"since": since, "months": [{"month": m, "n": sum(c.values()), "counts": {k: c.get(k, 0) for k in order}}
143
+ for m, c in sorted(by_month.items())],
144
+ "n": len(work), "counts": {k: total.get(k, 0) for k in order},
145
+ "shares": {k: round(total.get(k, 0) / len(work), 4) for k in order},
146
+ "decided_by": dict(how.most_common()), "examples": {k: v[-5:] for k, v in examples.items()},
147
+ "bots": len(merged) - len(work)}
148
+
149
+
150
+ def tracker_types(prs: list[dict], issues: list[dict]) -> dict[int, str]:
151
+ """Each PR's linked Jira or Linear issue type; with several, the most telling (incident, bug, requirement, task)."""
152
+ from stackvitals.connectors.tracker import pr_links
153
+ rank = {"INCIDENT": 0, "BUG": 1, "REQUIREMENT": 2, "TASK": 3}
154
+ types = {i["key"]: i.get("type") or "TASK" for i in issues}
155
+ out = {}
156
+ for n, key in pr_links(prs, issues):
157
+ if n not in out or rank.get(types[key], 3) < rank.get(out[n], 3):
158
+ out[n] = types[key]
159
+ return out
160
+
161
+
162
+ def by_epic(prs: list[dict], issues: list[dict], now: datetime, months: int = MONTHS, base: str | None = None) -> dict | None:
163
+ """Merged people's PRs linked to Jira or Linear issues, by the epic (Jira) or project (Linear) those belong to, and
164
+ the share linked to feature work (requirements), which teams often capitalise; None without a linked PR."""
165
+ from stackvitals.connectors.tracker import pr_links
166
+ first = (now.year * 12 + now.month - 1) - (months - 1)
167
+ since = f"{first // 12:04d}-{first % 12 + 1:02d}"
168
+ # Into the default branch only, as in the monthly mix: a backport would count the same work twice.
169
+ work = [p for p in prs if p.get("mg") and p["mg"][:7] >= since and kind_of(p.get("au"), p.get("at")) in ("person", "agent")
170
+ and (base is None or p.get("base") in (None, base))]
171
+ by_key = {i["key"]: i for i in issues}
172
+ linked = {}
173
+ for n, key in pr_links(work, issues):
174
+ linked.setdefault(n, []).append(by_key[key])
175
+ if not linked:
176
+ return None
177
+ epics, titles, features = Counter(), {}, 0
178
+ for found in linked.values():
179
+ tagged = [i for i in found if i.get("epic")]
180
+ if tagged:
181
+ epics[tagged[0]["epic"]] += 1
182
+ titles.setdefault(tagged[0]["epic"], tagged[0].get("epic_title"))
183
+ features += any(i.get("type") == "REQUIREMENT" for i in found)
184
+ total = len(linked)
185
+ return {"since": since, "linked": total, "no_epic": total - sum(epics.values()),
186
+ "epics": [{"epic": e, "title": titles.get(e), "prs": n, "share": round(n / total, 4)} for e, n in epics.most_common(10)],
187
+ "feature_share": round(features / total, 4)}
@@ -0,0 +1,43 @@
1
+ """Join keys between modules: CI runs to the pull requests they tested, and how far back the CI history has no gaps."""
2
+ from collections import defaultdict
3
+ from datetime import date
4
+
5
+ # pull_request_target runs test the base branch's code, not the PR's, so they say nothing about the PR's commits.
6
+ PR_EVENTS = {"pull_request"}
7
+
8
+
9
+ def runs_by_pr(runs: list[dict], prs: list[dict]) -> dict[int, list[dict]]:
10
+ """CI runs of pull_request events keyed by PR number: the number GitHub gives (same-repo PRs), else the PR with the
11
+ same head owner and branch that was open when the run was created (forks; a reused branch maps to the right PR)."""
12
+ numbers = {p["n"] for p in prs}
13
+ by_head = defaultdict(list)
14
+ for p in prs:
15
+ if p.get("ho") and p.get("head"):
16
+ by_head[(p["ho"].lower(), p["head"])].append(p)
17
+ out = defaultdict(list)
18
+ for r in runs:
19
+ if r.get("ev") not in PR_EVENTS:
20
+ continue
21
+ hits = [n for n in r.get("pr") or [] if n in numbers]
22
+ if not hits and r.get("ho") and r.get("br"):
23
+ cr = r.get("cr") or ""
24
+ open_then = [p for p in by_head.get((r["ho"].lower(), r["br"]), []) if p["cr"] <= cr <= (p.get("cl") or "9999")]
25
+ if open_then:
26
+ hits = [max(open_then, key=lambda p: p["cr"])["n"]]
27
+ if hits:
28
+ out[hits[0]].append(r)
29
+ return dict(out)
30
+
31
+
32
+ def coverage_start(state: dict, runs: list[dict]) -> str | None:
33
+ """Where the CI history is unbroken: the oldest day of the run of consecutive scanned days that ends at the newest one;
34
+ without that record, the oldest run kept."""
35
+ days = sorted((state or {}).get("scanned") or {}, reverse=True)
36
+ if days:
37
+ start = days[0]
38
+ for day in days[1:]:
39
+ if (date.fromisoformat(start) - date.fromisoformat(day)).days != 1:
40
+ break
41
+ start = day
42
+ return f"{start}T00:00:00Z"
43
+ return min((r["cr"] for r in runs if r.get("cr")), default=None)
@@ -0,0 +1,40 @@
1
+ """llms.txt at the repo root (llmstxt.org): the docs as a short index an AI agent reads first, each page with its title
2
+ and its first sentence, made from docs/ so it can't fall behind them (a test checks it is current)."""
3
+ import re
4
+ from pathlib import Path
5
+
6
+ # Shown under Optional: true but rarely what an agent needs first.
7
+ OPTIONAL = {"release-checklist.md", "threat-model.md", "roadmap.md", "chaoss.md", "enterprise.md"}
8
+ SUMMARY = ("A free, GitHub-native engineering health suite: one GitHub Action keeps CI, pull request, delivery (DORA), "
9
+ "community and dependency history on a data branch in your own repo, with a static app, a CLI and an MCP "
10
+ "server over it. No server of ours, no Docker.")
11
+
12
+
13
+ def _first_sentence(text: str) -> str:
14
+ """The first plain sentence after the title: links and code kept as words, at most 220 characters."""
15
+ body = []
16
+ for line in text.splitlines()[1:]:
17
+ if not line.strip():
18
+ if body:
19
+ break
20
+ continue
21
+ if line.startswith(("#", "|", "```", "-", ">", "<")):
22
+ if body:
23
+ break
24
+ continue
25
+ body.append(line.strip())
26
+ para = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", " ".join(body)).replace("**", "").replace("`", "")
27
+ first = re.split(r"(?<=[.!?])\s", para, maxsplit=1)[0]
28
+ return first if len(first) <= 220 else first[:217].rsplit(" ", 1)[0] + "..."
29
+
30
+
31
+ def markdown(root: Path) -> str:
32
+ docs = Path(root) / "docs"
33
+ pages = sorted(p for p in docs.rglob("*.md"))
34
+ main, optional = [], []
35
+ for p in pages:
36
+ text = p.read_text(encoding="utf-8")
37
+ title = text.splitlines()[0].lstrip("# ").strip() if text.strip() else p.stem
38
+ line = f"- [{title}]({p.relative_to(root).as_posix()}): {_first_sentence(text)}"
39
+ (optional if p.name in OPTIONAL else main).append(line)
40
+ return "\n".join(["# StackVitals", "", f"> {SUMMARY}", "", "## Docs", "", *main, "", "## Optional", "", *optional, ""])
@@ -0,0 +1,70 @@
1
+ """Merge-queue health, which GitHub itself doesn't report: how long pull requests wait in the queue before they merge,
2
+ why they get taken out, and how many removals followed a merge-group run that failed only on jobs known to be flaky."""
3
+ import re
4
+ from collections import Counter
5
+ from datetime import datetime, timedelta
6
+
7
+ from stackvitals.core.stats import percentile
8
+ from stackvitals.core.time import iso, parse
9
+
10
+ QUEUE_BRANCH = re.compile(r"^gh-readonly-queue/.+/pr-(\d+)-")
11
+ # A pull request also leaves the queue when it merges; those removals are not trouble. GraphQL spells reasons in lowercase
12
+ # (merged, merge_conflict, failed_checks), webhooks in uppercase, so case is ignored.
13
+ MERGED = {"MERGE", "MERGED", "ALREADY_MERGED"}
14
+ CLOSE_TO_MERGE = timedelta(minutes=5)
15
+ FLAKY = {"flaky", "suspect"}
16
+ FAILED = ("failure", "timed_out")
17
+ LOOK_BACK = timedelta(hours=3) # a removal is tied to the queue's failed runs for that PR shortly before it
18
+ MIN_N = 10 # fewer merges and the waits are left out (the count stays)
19
+
20
+
21
+ def _hours(a: str, b: str) -> float:
22
+ return round((parse(b) - parse(a)).total_seconds() / 3600, 2)
23
+
24
+
25
+ def health(prs: list[dict], ci: dict, job_states: dict, now: datetime, window_days: int, min_n: int = MIN_N) -> dict | None:
26
+ """{prs, merged, p50_h, p90_h, removals: {reason: n}, removed_by_flakes: [...], runs, failed_runs} over the window,
27
+ or None when nothing used a merge queue."""
28
+ since = iso(now - timedelta(days=window_days))
29
+ queued = [p for p in prs if any(e[1] >= since for e in p.get("mq") or [])]
30
+ if not queued:
31
+ return None
32
+ # A run's final attempt is its answer: a re-run is the same run, not another one.
33
+ final = {}
34
+ for r in ci.get("runs") or []:
35
+ if r.get("ev") == "merge_group" and (r.get("cr") or "") >= since and r.get("att", 1) >= final.get(r["id"], {}).get("att", 0):
36
+ final[r["id"]] = r
37
+ runs = list(final.values())
38
+ by_pr = {}
39
+ for r in runs:
40
+ found = QUEUE_BRANCH.match(r.get("br") or "")
41
+ if found:
42
+ by_pr.setdefault(int(found.group(1)), []).append(r)
43
+ failed_jobs = {}
44
+ for j in ci.get("jobs") or []:
45
+ if j.get("c") in FAILED:
46
+ failed_jobs.setdefault((j["run"], j.get("att", 1)), []).append(j.get("n"))
47
+ waits, removals, flaky = [], Counter(), []
48
+ for p in queued:
49
+ events = sorted(p.get("mq") or [], key=lambda e: e[1])
50
+ if p.get("mg"):
51
+ entered = [e[1] for e in events if e[0] == "add" and e[1] <= p["mg"]]
52
+ if entered:
53
+ waits.append(_hours(entered[-1], p["mg"]))
54
+ for e in events:
55
+ if e[0] != "rm" or e[1] < since:
56
+ continue
57
+ reason = (e[2] if len(e) > 2 else None) or "UNKNOWN"
58
+ if reason.upper() in MERGED or (p.get("mg") and abs(parse(e[1]) - parse(p["mg"])) <= CLOSE_TO_MERGE):
59
+ continue
60
+ removals[reason] += 1
61
+ # Every failing run just before the removal (several workflows can fail together), and every job that failed.
62
+ before = [r for r in by_pr.get(p["n"], []) if r.get("c") in FAILED and iso(parse(e[1]) - LOOK_BACK) <= r["cr"] <= e[1]]
63
+ jobs = [(r.get("path"), name) for r in before for name in failed_jobs.get((r["id"], r.get("att", 1))) or []]
64
+ if jobs and all(job_states.get(job) in FLAKY for job in jobs):
65
+ flaky.append({"pr": p["n"], "at": e[1], "jobs": sorted({name for _path, name in jobs})})
66
+ enough = len(waits) >= min_n
67
+ return {"prs": len(queued), "merged": len(waits), "p50_h": percentile(waits, 0.5) if enough else None,
68
+ "p90_h": percentile(waits, 0.9) if enough else None, "removals": dict(removals.most_common()),
69
+ "removed_by_flakes": flaky, "runs": len(runs),
70
+ "failed_runs": sum(1 for r in runs if r.get("c") in FAILED), "window_days": window_days}
@@ -0,0 +1,109 @@
1
+ """The metric registry: every number the suite shows has an id, a version, a one-sentence definition, a minimum sample,
2
+ and comes out as a Measure with n, a range and an as-of time. Benchmarks use the same ids, so a repo's number and its
3
+ peer percentile are the same definition by construction."""
4
+ from dataclasses import asdict, dataclass
5
+ from datetime import datetime
6
+
7
+ from stackvitals.core import stats
8
+ from stackvitals.core.time import iso
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Metric:
13
+ id: str
14
+ definition: str
15
+ unit: str # s, share, count, lines, ms
16
+ kind: str # median, p75, share, count
17
+ min_n: int = 1
18
+ better: int = 0 # -1 lower is better, 1 higher, 0 neither
19
+ version: int = 1
20
+
21
+
22
+ @dataclass
23
+ class Measure:
24
+ metric: str
25
+ version: int
26
+ value: float | None
27
+ n: int
28
+ low: float | None
29
+ high: float | None
30
+ unit: str
31
+ as_of: str
32
+ window_days: int | None = None
33
+ note: str | None = None
34
+
35
+ def to_dict(self) -> dict:
36
+ return asdict(self)
37
+
38
+
39
+ REGISTRY: dict[str, Metric] = {}
40
+
41
+
42
+ def register(metric: Metric) -> Metric:
43
+ REGISTRY[metric.id] = metric
44
+ return metric
45
+
46
+
47
+ def get(metric_id: str) -> Metric:
48
+ return REGISTRY[metric_id]
49
+
50
+
51
+ def all() -> list[Metric]: # noqa: A001
52
+ return list(REGISTRY.values())
53
+
54
+
55
+ def measure(metric_id: str, values: list[float] | None = None, hits: int | None = None, n: int | None = None,
56
+ as_of: datetime | str = "", window_days: int | None = None) -> Measure:
57
+ """One number with its evidence: a median or percentile of `values`, a share of `hits` in `n`, or a count `n`."""
58
+ m = get(metric_id)
59
+ stamp = as_of if isinstance(as_of, str) else iso(as_of)
60
+ low = high = value = None
61
+ if m.kind in ("median", "p75"):
62
+ values = [v for v in (values or []) if v is not None]
63
+ n = len(values)
64
+ q = 0.5 if m.kind == "median" else 0.75
65
+ if n >= m.min_n and n:
66
+ value = stats.median(values) if m.kind == "median" else stats.percentile(values, q)
67
+ rng = stats.quantile_range(values, q)
68
+ low, high = rng if rng else (None, None)
69
+ elif m.kind == "share":
70
+ n = int(n or 0)
71
+ if n >= m.min_n and n:
72
+ value = round((hits or 0) / n, 4)
73
+ low, high = (round(x, 4) for x in stats.wilson(hits or 0, n))
74
+ else:
75
+ n = int(n or 0)
76
+ value = n
77
+ note = None if value is not None or m.kind == "count" else f"not enough data (n={n}, needs {m.min_n})"
78
+ return Measure(m.id, m.version, value, n, low, high, m.unit, stamp, window_days, note)
79
+
80
+
81
+ # Review flow: LinearB's four phases, review split into its approve and merge metrics, on GitHub's own timestamps; DevLake's four fields are derived from these.
82
+ for _id, _definition, _min in (
83
+ ("flow.coding_time", "Seconds from the PR's first commit (authored) to the PR being opened; median.", 10),
84
+ ("flow.pickup_time", "Seconds from the PR being ready for review (opened, or leaving draft) to the first review submitted "
85
+ "by a person other than the author (approval, change request or review comment); bots and agents "
86
+ "don't count; median over merged PRs.", 10),
87
+ ("flow.review_time", "Seconds from the first review activity to the merge; median over merged PRs.", 10),
88
+ ("flow.approve_time", "Seconds from the first review activity to the first approval; median.", 10),
89
+ ("flow.merge_time", "Seconds from the first approval to the merge; median.", 10),
90
+ ("flow.deploy_time", "Seconds from the merge to the end of the first successful production deployment that contains it; median.", 10),
91
+ ("flow.cycle_time", "Seconds from ready for review to merged; median over merged PRs.", 10),
92
+ ):
93
+ register(Metric(_id, _definition, "s", "median", _min, -1))
94
+ register(Metric("flow.outsider_merge_rate", "Share of outside contributors' PRs (people who are not insiders; bots and "
95
+ "agents left out) that were merged, among those decided in the window.", "share", "share", 10, 1))
96
+ register(Metric("flow.open_prs", "Open PRs by people, drafts included.", "count", "count"))
97
+ register(Metric("community.first_answer_time", "Seconds from an outside PR or issue being opened to the first answer by a "
98
+ "maintainer (review, reply, merge or close by someone else); median.", "s", "median", 10, -1))
99
+ register(Metric("ci.flake_rate", "Share of runs of a job or test in the window that flaked (failed and passed on the same "
100
+ "code).", "share", "share", 5, -1))
101
+ register(Metric("ci.first_try_green", "Share of pushes to the default branch whose checks all passed on the first attempt.",
102
+ "share", "share", 5, 1))
103
+ register(Metric("ci.push_wait", "Seconds from a push to the default branch until all its checks finished; median.",
104
+ "s", "median", 5, -1))
105
+ # Cross-module: CI Health's runs joined to Team Flow's pull requests.
106
+ register(Metric("insight.red_ci_share", "Share of people's merged pull requests that spent time ready for review with a "
107
+ "failing check on their newest commit.", "share", "share", 10, -1))
108
+ register(Metric("insight.red_ci_hours", "Hours a merged pull request spent ready for review with a failing check on its "
109
+ "newest commit, among those that did; median.", "h", "median", 5, -1))