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
stackvitals/ci/api.py ADDED
@@ -0,0 +1,218 @@
1
+ import os
2
+ import threading
3
+ import time
4
+ from collections.abc import Callable, Iterator
5
+ from urllib.parse import quote
6
+
7
+ import httpx
8
+
9
+ # GitHub Enterprise Server: Actions sets GITHUB_API_URL and GITHUB_GRAPHQL_URL for every job there.
10
+ API = (os.environ.get("GITHUB_API_URL") or "https://api.github.com").rstrip("/")
11
+ GRAPHQL = os.environ.get("GITHUB_GRAPHQL_URL") or f"{API}/graphql"
12
+
13
+
14
+ class OutOfBudget(RuntimeError):
15
+ pass
16
+
17
+
18
+ class APIError(RuntimeError):
19
+ pass
20
+
21
+
22
+ class NotFound(APIError):
23
+ pass
24
+
25
+
26
+ class ActionsAPI:
27
+ # REST reads of Actions runs, attempts and jobs; stops cleanly before the token's limit is used up.
28
+ def __init__(self, token: str | None, repo: str, client: httpx.Client | None = None,
29
+ sleep: Callable[[float], None] = time.sleep, max_requests: int | None = None, reserve: int = 50,
30
+ etags: dict | None = None, cacheable: Callable[[str], bool] | None = None):
31
+ self.repo, self.sleep, self.max_requests, self.reserve = repo, sleep, max_requests, reserve
32
+ # Conditional reads: a 304 answer reuses the kept body and doesn't count against GitHub's hourly limit.
33
+ self.etags, self.cacheable, self.unchanged = etags, cacheable, 0
34
+ self.client = client or httpx.Client(timeout=httpx.Timeout(60.0, connect=15.0), follow_redirects=True)
35
+ self.headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28",
36
+ "User-Agent": "stackvitals"}
37
+ if token and token.strip():
38
+ self.headers["Authorization"] = f"Bearer {token.strip()}"
39
+ self.requests = 0
40
+ self._lock = threading.Lock()
41
+
42
+ def _get(self, path: str, params: dict | None = None, raw: bool = False):
43
+ for attempt in range(4):
44
+ # Requests run in parallel threads; the count and the budget check must not race.
45
+ with self._lock:
46
+ if self.max_requests is not None and self.requests - self.unchanged >= self.max_requests:
47
+ raise OutOfBudget(f"stopped after {self.requests} requests (budget)")
48
+ self.requests += 1
49
+ key = path + "?" + "&".join(f"{k}={v}" for k, v in sorted((params or {}).items()))
50
+ kept = self.etags.get(key) if self.etags is not None and not raw and self.cacheable and self.cacheable(path) else None
51
+ headers = {**self.headers, "If-None-Match": kept[0]} if kept else self.headers
52
+ try:
53
+ resp = self.client.get(f"{API}{path}", headers=headers, params=params)
54
+ except httpx.TransportError:
55
+ # Dropped connections happen on long collections; try again after a short pause.
56
+ self.sleep(2 * (attempt + 1))
57
+ continue
58
+ if resp.status_code in (403, 429) and ("retry-after" in resp.headers or "secondary" in resp.text.lower()):
59
+ # Secondary limits ask for a pause; waiting is the only allowed answer.
60
+ self.sleep(min(float(resp.headers.get("retry-after", 60)), 300))
61
+ continue
62
+ if resp.status_code in (403, 429) and resp.headers.get("x-ratelimit-remaining") == "0":
63
+ raise OutOfBudget("GitHub's hourly limit for this token is used up")
64
+ if resp.status_code >= 500 and attempt < 3:
65
+ # GitHub answers some heavy job lists with 502 now and then; a short pause usually fixes it.
66
+ self.sleep(3 * (attempt + 1))
67
+ continue
68
+ if resp.status_code == 304 and kept:
69
+ with self._lock:
70
+ self.unchanged += 1
71
+ return kept[1]
72
+ if resp.status_code >= 400:
73
+ raise APIError(f"GET {path}: HTTP {resp.status_code} {resp.text[:200]}")
74
+ small = len(resp.content) < 20000
75
+ if self.etags is not None and not raw and self.cacheable and self.cacheable(path) and resp.headers.get("etag") and small:
76
+ self.etags[key] = [resp.headers["etag"], resp.json()]
77
+ left = resp.headers.get("x-ratelimit-remaining")
78
+ if left is not None and int(left) <= self.reserve:
79
+ raise OutOfBudget(f"only {left} requests left this hour; keeping a reserve")
80
+ return resp.content if raw else resp.json()
81
+ raise OutOfBudget("GitHub kept asking to slow down")
82
+
83
+ def get_if_changed(self, path: str, params: dict | None, etag: str | None) -> tuple[dict | None, str | None]:
84
+ """(body, etag), or (None, etag) when GitHub says nothing changed since `etag` (which costs no hourly limit)."""
85
+ with self._lock:
86
+ if self.max_requests is not None and self.requests - self.unchanged >= self.max_requests:
87
+ raise OutOfBudget(f"stopped after {self.requests} requests (budget)")
88
+ self.requests += 1
89
+ headers = {**self.headers, "If-None-Match": etag} if etag else self.headers
90
+ try:
91
+ resp = self.client.get(f"{API}{path}", headers=headers, params=params)
92
+ except httpx.TransportError as exc:
93
+ raise APIError(f"GET {path}: {type(exc).__name__}") from None
94
+ if resp.status_code == 304:
95
+ with self._lock:
96
+ self.unchanged += 1
97
+ return None, etag
98
+ # A limit answer stops the caller (it keeps what it had), never reads as "not available".
99
+ if resp.status_code in (403, 429) and ("retry-after" in resp.headers or "secondary" in resp.text.lower()
100
+ or resp.headers.get("x-ratelimit-remaining") == "0"):
101
+ raise OutOfBudget("GitHub asked to slow down or the hourly limit is used up")
102
+ if resp.status_code >= 400:
103
+ raise APIError(f"GET {path}: HTTP {resp.status_code} {resp.text[:200]}")
104
+ left = resp.headers.get("x-ratelimit-remaining")
105
+ if left is not None and int(left) <= self.reserve:
106
+ raise OutOfBudget(f"only {left} requests left this hour; keeping a reserve")
107
+ return resp.json(), resp.headers.get("etag")
108
+
109
+ def fetch(self, path: str) -> tuple[int, bytes]:
110
+ """(status, body) for endpoints whose status says something, like 202 while a report is being made. Redirects are
111
+ followed; httpx drops the token when one leaves api.github.com."""
112
+ with self._lock:
113
+ if self.max_requests is not None and self.requests - self.unchanged >= self.max_requests:
114
+ raise OutOfBudget(f"stopped after {self.requests} requests (budget)")
115
+ self.requests += 1
116
+ try:
117
+ resp = self.client.get(f"{API}{path}", headers=self.headers)
118
+ except httpx.TransportError as exc:
119
+ raise APIError(f"GET {path}: {type(exc).__name__}") from None
120
+ if resp.status_code >= 400:
121
+ raise APIError(f"GET {path}: HTTP {resp.status_code} {resp.text[:200]}")
122
+ return resp.status_code, resp.content
123
+
124
+ def send(self, method: str, path: str, body: dict | None = None) -> dict:
125
+ # Writes (PR comments, re-runs) are counted against the same budget and never retried blindly.
126
+ if os.environ.get("STACKVITALS_READ_ONLY"):
127
+ # A run on someone's machine (`stackvitals ui --collect`) reads GitHub and never writes to it.
128
+ raise APIError(f"{method} {path}: read-only local run, nothing is written to GitHub")
129
+ with self._lock:
130
+ self.requests += 1
131
+ resp = self.client.request(method, f"{API}{path}", headers=self.headers, json=body)
132
+ if resp.status_code >= 400:
133
+ raise APIError(f"{method} {path}: HTTP {resp.status_code} {resp.text[:200]}")
134
+ return resp.json() if resp.content else {}
135
+
136
+ def runs_on_day(self, day: str) -> Iterator[dict]:
137
+ # Filtered run lists stop at 1,000 results, so callers ask one day at a time.
138
+ for page in range(1, 11):
139
+ body = self._get(f"/repos/{self.repo}/actions/runs",
140
+ {"created": day, "status": "completed", "per_page": 100, "page": page})
141
+ runs = body.get("workflow_runs") or []
142
+ yield from runs
143
+ if len(runs) < 100:
144
+ return
145
+
146
+ def file(self, path: str, ref: str | None, repo: str | None = None) -> bytes:
147
+ # One file from a branch, raw (used to read a repo's stackvitals-data findings in one request); Portal reads other repos.
148
+ with self._lock:
149
+ if self.max_requests is not None and self.requests - self.unchanged >= self.max_requests:
150
+ raise OutOfBudget(f"stopped after {self.requests} requests (budget)")
151
+ self.requests += 1
152
+ # Paths come from PRs: "#" or "?" in a folder name would otherwise cut the URL short.
153
+ for attempt in range(3):
154
+ try:
155
+ resp = self.client.get(f"{API}/repos/{repo or self.repo}/contents/{quote(path, safe='/')}",
156
+ params={"ref": ref} if ref else None,
157
+ headers={**self.headers, "Accept": "application/vnd.github.raw"})
158
+ break
159
+ except httpx.TransportError:
160
+ # A dropped connection is retried, then reported like any refusal: it must never stop a run.
161
+ if attempt == 2:
162
+ raise APIError(f"GET contents/{path}@{ref}: network error")
163
+ self.sleep(2 * (attempt + 1))
164
+ if resp.status_code == 404:
165
+ raise NotFound(f"GET contents/{path}@{ref}: HTTP 404")
166
+ if resp.status_code >= 400:
167
+ raise APIError(f"GET contents/{path}@{ref}: HTTP {resp.status_code}")
168
+ return resp.content
169
+
170
+ def file_if_changed(self, path: str, ref: str | None = None, etag: str | None = None) -> tuple[bytes | None, str | None]:
171
+ """(content, etag) of a file, or (None, etag) when it still matches `etag`: GitHub doesn't count a 304 against the
172
+ hourly limit, so a file read on every run costs nothing while it stays the same. No ref means the default branch."""
173
+ with self._lock:
174
+ if self.max_requests is not None and self.requests - self.unchanged >= self.max_requests:
175
+ raise OutOfBudget(f"stopped after {self.requests} requests (budget)")
176
+ self.requests += 1
177
+ headers = {**self.headers, "Accept": "application/vnd.github.raw", **({"If-None-Match": etag} if etag else {})}
178
+ for attempt in range(3):
179
+ try:
180
+ resp = self.client.get(f"{API}/repos/{self.repo}/contents/{quote(path, safe='/')}",
181
+ params={"ref": ref} if ref else None, headers=headers)
182
+ except httpx.TransportError:
183
+ if attempt == 2:
184
+ raise APIError(f"GET contents/{path}: network error")
185
+ self.sleep(2 * (attempt + 1))
186
+ continue
187
+ if resp.status_code >= 500 and attempt < 2:
188
+ self.sleep(2 * (attempt + 1))
189
+ continue
190
+ break
191
+ if resp.status_code == 304:
192
+ return None, resp.headers.get("etag") or etag
193
+ if resp.status_code == 404:
194
+ raise NotFound(f"GET contents/{path}: HTTP 404")
195
+ if resp.status_code >= 400:
196
+ raise APIError(f"GET contents/{path}: HTTP {resp.status_code}")
197
+ return resp.content, resp.headers.get("etag")
198
+
199
+ def artifacts(self, run_id: int) -> list[dict]:
200
+ body = self._get(f"/repos/{self.repo}/actions/runs/{run_id}/artifacts", {"per_page": 100})
201
+ return body.get("artifacts") or []
202
+
203
+ def download(self, artifact_id: int) -> bytes:
204
+ # GitHub answers with a redirect to storage; httpx drops the token when it leaves github.com.
205
+ return self._get(f"/repos/{self.repo}/actions/artifacts/{artifact_id}/zip", raw=True)
206
+
207
+ def attempt(self, run_id: int, n: int) -> dict:
208
+ return self._get(f"/repos/{self.repo}/actions/runs/{run_id}/attempts/{n}")
209
+
210
+ def jobs(self, run_id: int, n: int) -> list[dict]:
211
+ out, page = [], 1
212
+ while True:
213
+ body = self._get(f"/repos/{self.repo}/actions/runs/{run_id}/attempts/{n}/jobs", {"per_page": 100, "page": page})
214
+ batch = body.get("jobs") or []
215
+ out.extend(batch)
216
+ if len(batch) < 100:
217
+ return out
218
+ page += 1
@@ -0,0 +1,212 @@
1
+ import glob
2
+ import subprocess
3
+ from datetime import timedelta
4
+ from urllib.parse import urlencode
5
+
6
+ import httpx
7
+
8
+ from stackvitals.ci.api import ActionsAPI, APIError
9
+ from stackvitals.ci.feedback import SAFE_FLAKES
10
+ from stackvitals.ci.owners import owners_line
11
+ from stackvitals.ci.report import began_text
12
+ from stackvitals.ci.testreports import merge, parse_report
13
+
14
+ LABEL = "flaky-test"
15
+
16
+
17
+ def should_rerun(items: list[dict], run_attempt: int) -> bool:
18
+ # Re-run once, and only when every failure is a known flake or an outside service failing.
19
+ return run_attempt == 1 and bool(items) and all(i["verdict"] in ("known flaky", "outside failure") for i in items)
20
+
21
+
22
+ def rerun_failed_jobs(api: ActionsAPI, run_id: int) -> None:
23
+ api.send("POST", f"/repos/{api.repo}/actions/runs/{run_id}/rerun-failed-jobs")
24
+
25
+
26
+ def expired(result: dict, settings: dict | None, now) -> list[str]:
27
+ """Flaky tests quarantined on their own for longer than [ci.quarantine] max_days: they fail builds again until fixed."""
28
+ days = ((((settings or {}).get("ci") or {}).get("quarantine") or {}).get("max_days")) or 0
29
+ if not days or now is None:
30
+ return []
31
+ cut = (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
32
+ return sorted(t["id"] for t in result.get("tests", []) if t["state"] == "flaky" and (t.get("since") or "~") < cut)
33
+
34
+
35
+ def quarantine_list(result: dict, settings: dict | None = None, decisions: dict | None = None, now=None) -> list[str]:
36
+ """Flaky tests, plus the repo's "always" list and tests someone quarantined, minus its "never" list, tests someone
37
+ took out of quarantine (.github/stackvitals.toml and the decisions log) and flaky tests past max_days."""
38
+ q = ((settings or {}).get("ci") or {}).get("quarantine") or {}
39
+ # Only tests that flaked as often as a safe re-run needs are let through on their own; one flake isn't enough.
40
+ flaky = {t["id"] for t in result.get("tests", []) if t["state"] == "flaky" and t.get("flake_events", 0) >= SAFE_FLAKES}
41
+ flaky -= set(expired(result, settings, now))
42
+ picked = lambda verb: {d["id"] for d in (decisions or {}).values() if d.get("kind") == "test" and d.get("verb") == verb}
43
+ return sorted((flaky | set(q.get("always") or []) | picked("quarantine")) - set(q.get("never") or []) - picked("unquarantine"))
44
+
45
+
46
+ def job_key(j: dict) -> str:
47
+ # How people name a job in decisions and issue titles: the workflow file, then the job.
48
+ return f"{(j.get('workflow') or '?').rsplit('/', 1)[-1]} / {j['job']}"
49
+
50
+
51
+ def apply_decisions(result: dict, decisions: dict) -> None:
52
+ """Each test and job carries the decision made about it; muted or acknowledged ones raise no alert and get no new
53
+ issue (acknowledged has no end)."""
54
+ for kind, items, key in (("test", result.get("tests") or [], lambda t: t["id"]), ("job", result.get("jobs") or [], job_key)):
55
+ for item in items:
56
+ d = decisions.get(f"{kind}:{key(item)}")
57
+ if not d:
58
+ continue
59
+ item["decision"] = {k: d.get(k) for k in ("verb", "by", "at", "until", "reason")}
60
+ if d.get("verb") in ("mute", "acknowledge"):
61
+ item["muted_until"] = d.get("until") or "9999-12-31"
62
+
63
+
64
+ def quarantine_exit(code: int, failed: list[str], quarantined: set | None) -> int:
65
+ # Pass only when the run failed solely on quarantined tests; an unreadable list quarantines nothing.
66
+ if code == 0 or quarantined is None or not failed:
67
+ return code
68
+ return 0 if set(failed) <= quarantined else code
69
+
70
+
71
+ def run_quarantined(command: list[str], report_globs: list[str], quarantined: set | None) -> int:
72
+ code = subprocess.run(command).returncode
73
+ if code == 0:
74
+ return 0
75
+ parts = []
76
+ for pattern in report_globs:
77
+ for path in glob.glob(pattern, recursive=True):
78
+ with open(path, "rb") as f:
79
+ parsed = parse_report(path, f.read())
80
+ if parsed:
81
+ parts.append(parsed)
82
+ return quarantine_exit(code, [f[0] for f in merge(parts)["failed"]], quarantined)
83
+
84
+
85
+ def _flaky_names(result: dict | None) -> dict[str, str]:
86
+ out = {}
87
+ for t in (result or {}).get("tests", []):
88
+ # A muted test is still measured, but nobody is alerted or given an issue until the mute ends.
89
+ if t["state"] == "flaky" and not t.get("muted_until"):
90
+ out[f"test {t['id']}"] = f"Flaky test: {t['id']}"
91
+ for j in (result or {}).get("jobs", []):
92
+ if j["state"] == "flaky" and not j.get("muted_until"):
93
+ out[f"job {job_key(j)}"] = f"Flaky job: {job_key(j)}"
94
+ return out
95
+
96
+
97
+ def new_flakes(old: dict | None, new: dict) -> list[str]:
98
+ # The first run has nothing to compare with, so it never alerts.
99
+ if old is None:
100
+ return []
101
+ before = _flaky_names(old)
102
+ return [k for k in _flaky_names(new) if k not in before]
103
+
104
+
105
+ def _owners_by_key(result: dict | None) -> dict[str, list[str]]:
106
+ out = {f"test {t['id']}": t.get("owners") for t in (result or {}).get("tests", [])}
107
+ out.update({f"job {(j.get('workflow') or '?').rsplit('/', 1)[-1]} / {j['job']}": j.get("owners")
108
+ for j in (result or {}).get("jobs", [])})
109
+ return out
110
+
111
+
112
+ def alert_message(names: list[str], repo: str, result: dict | None = None, links=()) -> dict:
113
+ from stackvitals.core import chat
114
+ owners = _owners_by_key(result)
115
+ lines = [f"- `{chat.safe(n)}`" + (f" (owners {' '.join(owners[n])})" if owners.get(n) else "") for n in names]
116
+ return chat.message(f"Newly flaky in {repo}", lines, links,
117
+ {"repo": repo, "alerts": [{"kind": "newly_flaky", "name": n} for n in names]})
118
+
119
+
120
+ def owners_of(names: list[str], result: dict | None) -> dict[str, list[str]]:
121
+ owners = _owners_by_key(result)
122
+ return {n: owners.get(n) or [] for n in names}
123
+
124
+
125
+ def send_alert(url: str, msg: dict, client: httpx.Client | None = None) -> str | None:
126
+ """Every chat (Teams included) gets its own shape; returns what went wrong, if anything."""
127
+ from stackvitals.core import chat
128
+ return chat.post(url, msg, client, "the flaky-test webhook")
129
+
130
+
131
+ def _evidence(entry: dict) -> str:
132
+ last = (entry.get("last_flake") or "")[:10]
133
+ began = began_text(entry.get("began"))
134
+ return (f"StackVitals saw this fail and then pass with no code change {entry.get('flake_events', 0)} times "
135
+ f"in {entry.get('runs', 0)} runs over the last 30 days" + (f" (last on {last})" if last else "") + "."
136
+ + (f" It {began}." if began else ""))
137
+
138
+
139
+ def _issue_body(entry: dict, mention: bool = False, linear: bool = False) -> str:
140
+ owners = owners_line(entry, mention)
141
+ name = entry.get("id") or f"{(entry.get('workflow') or '?').rsplit('/', 1)[-1]} / {entry.get('job')}"
142
+ # A team that tracks work in Linear files it there in one click, prefilled; no token is needed.
143
+ file_it = (f"\n\n[File it in Linear](https://linear.new?{urlencode({'title': f'Flaky: {name}', 'description': _evidence(entry)})})"
144
+ if linear else "")
145
+ return (_evidence(entry) + (f"\n\n{owners}" if owners else "") + file_it
146
+ + "\n\nThis issue is kept up to date automatically and closed once it stays quiet for two weeks. "
147
+ "Close it as *not planned* if the team accepts this flake: StackVitals keeps it closed.")
148
+
149
+
150
+ def _first_person(entry: dict) -> str | None:
151
+ # CODEOWNERS entries are @user, @org/team or e-mail addresses; only a user can be assigned.
152
+ for owner in entry.get("owners") or []:
153
+ if owner.startswith("@") and "/" not in owner:
154
+ return owner[1:]
155
+ return None
156
+
157
+
158
+ def _all_issues(api: ActionsAPI) -> dict[str, dict]:
159
+ """The newest issue for each title, open or closed, so a decision someone made on it is respected."""
160
+ found = {}
161
+ for page in range(1, 4):
162
+ batch = api._get(f"/repos/{api.repo}/issues", {"labels": LABEL, "state": "all", "per_page": 100, "page": page}) or []
163
+ for issue in batch:
164
+ if "pull_request" not in issue and issue["number"] > found.get(issue["title"], {}).get("number", 0):
165
+ found[issue["title"]] = issue
166
+ if len(batch) < 100:
167
+ break
168
+ return found
169
+
170
+
171
+ def sync_issues(api: ActionsAPI, result: dict, mention_owners: bool = False, assign_owners: bool = False,
172
+ linear: bool = False) -> dict:
173
+ """One issue per flaky test or job for its whole life: updated while flaky, closed once fixed, reopened with fresh
174
+ evidence when it flakes again after being closed as done. Closed as "not planned" (or a duplicate) is the team's
175
+ decision to live with it: it stays closed and nothing new is opened. Owners are named in code format (no
176
+ notification) unless mention_owners is on; assign_owners assigns the first owner who is a person."""
177
+ issues = _all_issues(api)
178
+ # Muted tests get no new issue; one already open stays as it is until the mute ends.
179
+ entries = {f"Flaky test: {t['id']}": t for t in result.get("tests", []) if not t.get("muted_until")}
180
+ entries.update({f"Flaky job: {job_key(j)}": j for j in result.get("jobs", []) if not j.get("muted_until")})
181
+ counts = {"created": 0, "updated": 0, "closed": 0, "reopened": 0, "kept_closed": 0}
182
+ for title, entry in entries.items():
183
+ issue = issues.get(title)
184
+ if entry["state"] == "flaky":
185
+ if issue and issue.get("state") == "open":
186
+ api.send("PATCH", f"/repos/{api.repo}/issues/{issue['number']}", {"body": _issue_body(entry, mention_owners, linear)})
187
+ counts["updated"] += 1
188
+ elif issue and issue.get("state_reason") not in (None, "completed", "reopened"):
189
+ counts["kept_closed"] += 1
190
+ elif issue and issue.get("closed_at") and (entry.get("last_flake") or "")[:19] <= issue["closed_at"][:19]:
191
+ pass # closed as done after its last flake: it stays closed unless it flakes again
192
+ elif issue:
193
+ api.send("PATCH", f"/repos/{api.repo}/issues/{issue['number']}",
194
+ {"state": "open", "body": _issue_body(entry, mention_owners, linear)})
195
+ api.send("POST", f"/repos/{api.repo}/issues/{issue['number']}/comments",
196
+ {"body": "Flaky again. " + _evidence(entry)})
197
+ counts["reopened"] += 1
198
+ else:
199
+ body = {"title": title, "body": _issue_body(entry, mention_owners, linear), "labels": [LABEL]}
200
+ person = _first_person(entry) if assign_owners else None
201
+ try:
202
+ api.send("POST", f"/repos/{api.repo}/issues", {**body, **({"assignees": [person]} if person else {})})
203
+ except APIError:
204
+ if not person:
205
+ raise
206
+ # Someone without access to the repo can't be assigned; the issue still matters.
207
+ api.send("POST", f"/repos/{api.repo}/issues", body)
208
+ counts["created"] += 1
209
+ elif issue and issue.get("state") == "open" and entry["state"] == "fixed":
210
+ api.send("PATCH", f"/repos/{api.repo}/issues/{issue['number']}", {"state": "closed", "state_reason": "completed"})
211
+ counts["closed"] += 1
212
+ return counts
@@ -0,0 +1,195 @@
1
+ import json
2
+ import math
3
+ import os
4
+ import time
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+
10
+ from mergescout.bench.ci import repo_stats, tier
11
+ from mergescout.github.releases import ReleaseError, Releases
12
+ from stackvitals.core.stats import median_range, shrink, wilson
13
+
14
+ # The public repo whose bench-latest release holds copies of the CI and shared benchmark files ("owner/repo").
15
+ PUBLIC_HOME: str | None = "stackvitals/stackvitals"
16
+ # Built daily by the crawler from about 90k active public repos; the private store keeps every file, read with a token.
17
+ BENCH_REPO, BENCH_TAG, BENCH_FILE = "kaushik-yadav/merge-scout", "bench-latest", "ci-benchmarks.json"
18
+
19
+
20
+ def _asset_url(name: str) -> str | None:
21
+ return f"https://github.com/{PUBLIC_HOME}/releases/download/{BENCH_TAG}/{name}" if PUBLIC_HOME else None
22
+
23
+
24
+ # A missing or unreachable public file (before launch, say) just leaves the comparison out.
25
+ DEFAULT_URL = _asset_url(BENCH_FILE)
26
+ RANKS = (0.10, 0.25, 0.50, 0.75, 0.90)
27
+ EVIDENCE_MAX = 300 # pushes listed behind the first-try share, newest first
28
+
29
+
30
+ def _push_runs(data: dict) -> list[dict]:
31
+ final = {}
32
+ for r in data.get("runs", []):
33
+ if r.get("ev") == "push" and r["att"] >= final.get(r["id"], {}).get("att", 0):
34
+ final[r["id"]] = r
35
+ return [{"id": r["id"], "head_sha": r.get("sha"), "path": r.get("path"), "conclusion": r.get("c"),
36
+ "head_branch": r.get("br"), "run_attempt": r["att"], "status": "completed", "created_at": r.get("cr"),
37
+ "run_started_at": r.get("st"), "updated_at": r.get("up")} for r in final.values()]
38
+
39
+
40
+ def push_stats(data: dict, now: datetime, days: int = 30) -> dict | None:
41
+ """This repo's pushes measured exactly like the benchmark sampler measures every other repo, with each number's
42
+ likely range."""
43
+ out = repo_stats(_push_runs(data), since=now - timedelta(days=days), durations=True, evidence=EVIDENCE_MAX)
44
+ if not out:
45
+ return out
46
+ durations = out.pop("durations")
47
+ green = round(out["first_try_share"] * out["pushes"])
48
+ out["first_try_range"] = [round(x, 4) for x in wilson(green, out["pushes"])]
49
+ rng = median_range(durations)
50
+ out["median_range_s"] = [round(x) for x in rng] if rng else None
51
+ return out
52
+
53
+
54
+ def push_window(data: dict, start: str, end: str) -> dict | None:
55
+ """The push measure over runs created from `start` up to `end`, with each push's wait kept for change tests."""
56
+ return repo_stats([r for r in _push_runs(data) if r["created_at"] and start <= r["created_at"] < end], durations=True)
57
+
58
+
59
+ def push_stats_by(data: dict, period) -> dict[str, dict]:
60
+ """The same push measure for each period (a week, say); periods with too few pushes are left out."""
61
+ groups = {}
62
+ for r in _push_runs(data):
63
+ if r["created_at"]:
64
+ groups.setdefault(period(r["created_at"]), []).append(r)
65
+ return {k: s for k, g in groups.items() if (s := repo_stats(g))}
66
+
67
+
68
+ def load(token: str | None, cache_dir: Path, client: httpx.Client | None = None, name: str = BENCH_FILE,
69
+ url_env: str = "STACKVITALS_BENCH_URL", validate=None, default_url: str | None = DEFAULT_URL) -> dict | None:
70
+ """The latest benchmarks, or None: comparisons are extra and must never break a run."""
71
+ validate = validate or _valid
72
+ source = os.environ.get(url_env) or default_url
73
+ if not source and not token:
74
+ return None
75
+ cache = Path(cache_dir) / name
76
+ try:
77
+ if source and not source.startswith(("http://", "https://")):
78
+ return validate(json.loads(Path(source).read_text(encoding="utf-8")))
79
+ if cache.exists() and time.time() - cache.stat().st_mtime < 86400:
80
+ return validate(json.loads(cache.read_text(encoding="utf-8")))
81
+ cache.parent.mkdir(parents=True, exist_ok=True)
82
+ if source and _fetch(source, cache, client):
83
+ return validate(json.loads(cache.read_text(encoding="utf-8")))
84
+ # A token falls back to the private store, so a missing public copy changes nothing for those who can read it.
85
+ if not token or (source and os.environ.get(url_env)):
86
+ return None
87
+ if not Releases(token, BENCH_REPO, client=client).download(BENCH_TAG, name, cache):
88
+ return None
89
+ return validate(json.loads(cache.read_text(encoding="utf-8")))
90
+ except (httpx.HTTPError, ReleaseError, OSError, ValueError):
91
+ return None
92
+
93
+
94
+ def _fetch(url: str, cache: Path, client: httpx.Client | None) -> bool:
95
+ try:
96
+ # Release downloads redirect to a file host; the caller's client may not follow redirects by default.
97
+ resp = (client or httpx.Client(timeout=30)).get(url, follow_redirects=True)
98
+ resp.raise_for_status()
99
+ except httpx.HTTPError:
100
+ return False
101
+ cache.write_bytes(resp.content)
102
+ return True
103
+
104
+
105
+ def _group_ok(g) -> bool:
106
+ nums = lambda q: isinstance(q, dict) and all(isinstance(q.get(f"p{int(r * 100)}"), (int, float)) for r in RANKS) # noqa: E731
107
+ return (isinstance(g, dict) and isinstance(g.get("repos"), int) and not isinstance(g.get("repos"), bool)
108
+ and nums(g.get("median_s")) and nums(g.get("first_try_share")))
109
+
110
+
111
+ def _valid(bench) -> dict | None:
112
+ # Only well-formed groups are kept, so a bad file can neither break a run nor reach a page.
113
+ if not isinstance(bench, dict) or not isinstance(bench.get("groups"), dict):
114
+ return None
115
+ groups = {k: g for k, g in bench["groups"].items() if isinstance(k, str) and k.count("|") == 1 and _group_ok(g)}
116
+ return {**bench, "groups": groups} if groups else None
117
+
118
+
119
+ # Who every comparison is with: the crawl's sample, which may not resemble a private team's work.
120
+ PEERS_ARE = "active public open-source repos (100+ stars, pushed in the last 90 days)"
121
+ # Opt-in figures that repos filed themselves ([benchmarks] share), public and private; published at launch.
122
+ SHARED_FILE = "shared-benchmarks.json"
123
+ SHARED_URL = _asset_url(SHARED_FILE)
124
+ SHARED_ARE = "repos that chose to share their rounded CI figures, public and private, latest month each"
125
+
126
+
127
+ def fit(key: str, language: str | None, size: str) -> str:
128
+ # The closest group is a loose fit when it had to drop the language or the size; the pages say so.
129
+ return "" if key == f"{language}|{size}" else "a loose fit: too few repos share this language and size, so a wider group"
130
+
131
+
132
+ def _label(key: str, everyone: str = "public repos we track") -> str:
133
+ lang, size = key.split("|")
134
+ stars = f"with {size} stars" if size != "all" else ""
135
+ if lang == "all":
136
+ return f"repos {stars}" if stars else everyone
137
+ return f"{lang} repos {stars}".strip()
138
+
139
+
140
+ def _rank(value: float, q: dict) -> float:
141
+ # Rough place in the group from its published percentiles, kept between 10% and 90%.
142
+ points = [(r, q[f"p{int(r * 100)}"]) for r in RANKS]
143
+ if value <= points[0][1]:
144
+ return RANKS[0]
145
+ if value >= points[-1][1]:
146
+ return RANKS[-1]
147
+ for (r1, v1), (r2, v2) in zip(points, points[1:]):
148
+ if v1 <= value <= v2 and v2 > v1:
149
+ return r1 + (value - v1) / (v2 - v1) * (r2 - r1)
150
+ return 0.5
151
+
152
+
153
+ def _about(share: float) -> int:
154
+ return int(math.floor(share * 100 / 5) * 5)
155
+
156
+
157
+ def _pulled(stats: dict, group: dict) -> dict:
158
+ # The share pulled toward the group's prior: few pushes move little; many keep the repo's own share.
159
+ prior = group.get("first_try_prior")
160
+ if not (isinstance(prior, list) and len(prior) == 2 and all(isinstance(x, (int, float)) and x > 0 for x in prior)):
161
+ return {}
162
+ green = round(stats["first_try_share"] * stats["pushes"])
163
+ return {"pulled": shrink(green, stats["pushes"], tuple(prior))}
164
+
165
+
166
+ def compare(stats: dict | None, bench: dict | None, language: str | None, stars: int | None,
167
+ everyone: str = "public repos we track") -> dict | None:
168
+ """Where this repo stands among similar public repos: the closest group with enough repos."""
169
+ bench = _valid(bench) if bench else None
170
+ if not stats or not bench:
171
+ return None
172
+ groups, size = bench["groups"], tier(stars)
173
+ key = next((k for k in (f"{language}|{size}", f"{language}|all", f"all|{size}", "all|all") if k in groups), None)
174
+ if key is None:
175
+ return None
176
+ g = groups[key]
177
+ r = _rank(stats["median_s"], g["median_s"])
178
+ time_place = f"slower than about {_about(r)}%" if r >= 0.5 else f"faster than about {_about(1 - r)}%"
179
+ r2 = _rank(stats["first_try_share"], g["first_try_share"])
180
+ green_place = f"above about {_about(r2)}%" if r2 >= 0.5 else f"below about {_about(1 - r2)}%"
181
+ return {"group": _label(key, everyone), "repos": g["repos"], "built_at": bench.get("built_at"), "fit": fit(key, language, size),
182
+ "metrics": [{"name": "Time from push to all checks done", "kind": "time", "you": stats["median_s"],
183
+ "typical": g["median_s"]["p50"], "place": time_place},
184
+ {"name": "Pushes green on the first try", "kind": "share", "you": stats["first_try_share"],
185
+ "typical": g["first_try_share"]["p50"], "place": green_place, **_pulled(stats, g)}]}
186
+
187
+
188
+ def badge(stats: dict | None) -> dict:
189
+ """A shields.io endpoint: the share of pushes whose checks all passed on the first try."""
190
+ if not stats:
191
+ return {"schemaVersion": 1, "label": "CI first try", "message": "no data", "color": "lightgrey"}
192
+ share = stats["first_try_share"]
193
+ color = next(c for floor, c in ((0.9, "brightgreen"), (0.8, "green"), (0.7, "yellow"), (0.5, "orange"), (0, "red"))
194
+ if share >= floor)
195
+ return {"schemaVersion": 1, "label": "CI first try", "message": f"{share:.0%}", "color": color}