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,435 @@
1
+ import hashlib
2
+ import html
3
+ import re
4
+ from collections import Counter
5
+ from urllib.parse import quote
6
+
7
+ from stackvitals import shell
8
+ from stackvitals.ci.analyze import BEGAN_MIN_RUNS, week_of
9
+ from stackvitals.ci.bench import PEERS_ARE
10
+
11
+ STATE_TEXT = {"flaky": "flaky", "suspect": "one flake seen", "broken": "keeps failing", "fixed": "fixed", "ok": "ok"}
12
+
13
+
14
+ def _dur(secs) -> str:
15
+ if secs is None:
16
+ return "-"
17
+ secs = round(secs)
18
+ return f"{secs // 60}m {secs % 60:02d}s" if secs >= 60 else f"{secs}s"
19
+
20
+
21
+ def _wf(path: str | None) -> str:
22
+ return (path or "?").rsplit("/", 1)[-1]
23
+
24
+
25
+ def _md(text) -> str:
26
+ # Names come from workflow files, which a fork PR can edit: escape HTML and Markdown control characters.
27
+ return re.sub(r"([\\`*_\[\]|<>#])", r"\\\1", str(text))
28
+
29
+
30
+ def _groups(result: dict) -> dict:
31
+ jobs = result["jobs"]
32
+ return {
33
+ "flaky": sorted((j for j in jobs if j["state"] in ("flaky", "suspect")),
34
+ key=lambda j: (j["state"] != "flaky", -j["flake_events"], -j["retry_minutes"])),
35
+ "broken": [j for j in jobs if j["state"] == "broken"],
36
+ "slower": sorted((j for j in jobs if j["slower"]), key=lambda j: -j["slower"]["pct"]),
37
+ "slowest": sorted((j for j in jobs if j["median_s"]), key=lambda j: -j["median_s"])[:10],
38
+ }
39
+
40
+
41
+ def _shown(flaky: list[dict], suspects: int = 5) -> tuple[list[dict], int]:
42
+ # Every flaky job is listed; one-off flakes beyond the first few become one line, so they don't bury the rest.
43
+ real = [j for j in flaky if j["state"] == "flaky"]
44
+ once = [j for j in flaky if j["state"] != "flaky"]
45
+ return real + once[:suspects], max(0, len(once) - suspects)
46
+
47
+
48
+ NO_REPORTS = ("No test reports found in this repo's artifacts, so only whole jobs are judged. To see single flaky "
49
+ "tests, save your JUnit XML, CTRF, Jest or Vitest JSON, go test -json or TRX file with "
50
+ "actions/upload-artifact under a name containing "
51
+ "\"test-results\" or \"junit\".")
52
+
53
+
54
+ def _ms(ms) -> str:
55
+ return "-" if ms is None else f"{ms / 1000:.1f}s"
56
+
57
+
58
+ def _test_groups(result: dict) -> dict:
59
+ tests = result.get("tests") or []
60
+ return {"flaky": sorted((t for t in tests if t["state"] in ("flaky", "suspect")),
61
+ key=lambda t: (t["state"] != "flaky", -t["flake_events"])),
62
+ "broken": [t for t in tests if t["state"] == "broken"],
63
+ "slow": result.get("slow_tests") or []}
64
+
65
+
66
+ def began_text(b: dict | None) -> str:
67
+ """When a flake began, as one clause, only as sure as the runs before it allow."""
68
+ if not b:
69
+ return ""
70
+ day = b["first"][:10]
71
+ if b["clear"]:
72
+ landed = ", ".join(s[:7] for s in b["landed"])
73
+ return (f"began around {day}: {b['runs_before']} runs before it never flaked (chance p = {b['p']:g})"
74
+ + (f"; reached the default branch the day before: {landed}" if landed else ""))
75
+ if b["runs_before"] < BEGAN_MIN_RUNS:
76
+ return f"first flaked {day}; only {b['runs_before']} runs before it, too few to say it began then"
77
+ return f"first flaked {day}, but flakes before it would have been rare anyway (p = {b['p']:g})"
78
+
79
+
80
+ def _test_line(t: dict) -> str:
81
+ rate = f"{t['flake_rate']:.0%} (likely {t['rate_low']:.0%}-{t['rate_high']:.0%})"
82
+ msg = f"; fails with \"{t['message']}\"" if t.get("message") else ""
83
+ kind = f"; looks like a {t['flake']} flake" if t.get("flake") else ""
84
+ began = f"; {began_text(t['began'])}" if t.get("began") else ""
85
+ return f"{t['flake_events']} flakes in {t['runs']} runs, {rate}{msg}{kind}; last {(t['last_flake'] or '')[:10]}{began}"
86
+
87
+
88
+ def _flaky_line(j: dict) -> str:
89
+ rate = f"{j['flake_rate']:.0%} (likely {j['rate_low']:.0%}-{j['rate_high']:.0%})"
90
+ step = f"; fails in \"{j['top_step']}\"" if j["top_step"] else ""
91
+ lost = f"; {j['retry_minutes']:.0f} min lost to re-runs" if j["retry_minutes"] else ""
92
+ began = f"; {began_text(j['began'])}" if j.get("began") else ""
93
+ return (f"{j['flake_events']} flakes in {j['runs']} runs, {rate}{step}{lost}; last {(j['last_flake'] or '')[:10]}{began}")
94
+
95
+
96
+ def _bench(result: dict, esc=str) -> tuple[str, list[str]] | None:
97
+ # How this repo's pushes compare with similar public repos, from the crawl's daily sample.
98
+ b = result.get("benchmark")
99
+ if not b:
100
+ return None
101
+ lines = []
102
+ for m in b["metrics"]:
103
+ fmt = _dur if m["kind"] == "time" else (lambda v: f"{v:.0%}")
104
+ line = f"{m['name']}: {fmt(m['you'])} (typical {fmt(m['typical'])}), {m['place']}"
105
+ if m.get("pulled") and m["pulled"]["n"] < 30:
106
+ # Under 30 pushes the share alone is noisy: say what the repo and its peers together suggest.
107
+ p = m["pulled"]
108
+ line += f"; from only {p['n']} pushes, similar repos suggest {fmt(p['value'])} (likely {fmt(p['low'])} to {fmt(p['high'])})"
109
+ lines.append(line)
110
+ loose = f" ({esc(b['fit'])})" if b.get("fit") else ""
111
+ peers = esc(b["peers"]) if b.get("peers") else f"{PEERS_ARE}, so it may not resemble a private team's work"
112
+ return (f"Compared with {int(b['repos'])} {esc(b['group'])}{loose}, each push until all its checks finished. The group is "
113
+ f"{peers}:"), lines
114
+
115
+
116
+ def render_text(result: dict, repo: str) -> str:
117
+ if not result["runs"]:
118
+ return f"{repo}: No completed CI runs in the last {result['window_days']} days."
119
+ g = _groups(result)
120
+ m = result["minutes"]
121
+ out = [f"{repo}: CI health over the last {result['window_days']} days ({result['runs']} runs, "
122
+ f"{result['rerun_share']:.0%} re-run, {result['infra_events']} outage(s); queue median "
123
+ f"{_dur(result['queue']['median_s'])}; minutes linux {m['linux']}, windows {m['windows']}, macos {m['macos']})", ""]
124
+ if bench := _bench(result):
125
+ out += [bench[0]] + [f" {line}" for line in bench[1]] + [""]
126
+ tg = _test_groups(result)
127
+ if result.get("has_test_reports"):
128
+ out.append("Flaky tests (fail, then pass with no code change):")
129
+ tshown, trest = _shown(tg["flaky"])
130
+ out += [f" [{STATE_TEXT[t['state']]}] {t['id']}: {_test_line(t)}" for t in tshown] or [" none found"]
131
+ if trest:
132
+ out.append(f" ... and {trest} more tests with one flake each")
133
+ if tg["broken"]:
134
+ out += [f" [keeps failing] {t['id']}: failed {t['failures']} of {t['runs']} runs"
135
+ + (f"; \"{t['message']}\"" if t.get("message") else "") for t in tg["broken"]]
136
+ out.append("")
137
+ else:
138
+ out += [NO_REPORTS, ""]
139
+ out.append("Flaky jobs (fail, then pass with no code change):")
140
+ shown, rest = _shown(g["flaky"])
141
+ out += [f" [{STATE_TEXT[j['state']]}] {_wf(j['workflow'])} / {j['job']}: {_flaky_line(j).replace(' lost to re-runs', ' lost')}"
142
+ for j in shown] or [" none found"]
143
+ if rest:
144
+ out.append(f" ... and {rest} more jobs with one flake each")
145
+ if g["broken"]:
146
+ out += ["", "Keeps failing (not flaky, really broken):"]
147
+ out += [f" {_wf(j['workflow'])} / {j['job']}: keeps failing ({j['failures']} of {j['runs']} runs failed)"
148
+ for j in g["broken"]]
149
+ if g["slower"]:
150
+ out += ["", "Getting slower (last 7 days vs before):"]
151
+ out += [f" {_wf(j['workflow'])} / {j['job']}: {_dur(j['slower']['before_s'])} -> {_dur(j['slower']['after_s'])} "
152
+ f"({j['slower']['after_s'] / j['slower']['before_s']:.1f}x slower)".replace(".0x", "x") for j in g["slower"]]
153
+ out += ["", "Slowest jobs (median, 95th percentile):"]
154
+ out += [f" {_dur(j['median_s']):>8} {_dur(j['p95_s']):>8} {_wf(j['workflow'])} / {j['job']}" for j in g["slowest"]]
155
+ if tg["slow"]:
156
+ out += ["", "Slowest tests (median, 95th percentile):"]
157
+ out += [f" {_ms(t['median_ms']):>8} {_ms(t['p95_ms']):>8} {t['id']}"
158
+ + (f" (+{t['slower']['pct']}% in the last 7 days)" if t["slower"] else "") for t in tg["slow"][:10]]
159
+ if result.get("new_tests"):
160
+ out += ["", "New tests in the last 14 days (runs since, failed, state):"]
161
+ out += [f" {t['runs']:>4} {t['failures']:>4} {t['state']:<8} {t['id']}" for t in result["new_tests"][:10]]
162
+ if result.get("skipped_tests"):
163
+ out += ["", "Skipped on the default branch (days):"]
164
+ out += [f" {t['days']:>4} {t['id']}" + (" (newly skipped)" if t["new"] else "") for t in result["skipped_tests"][:10]]
165
+ return "\n".join(out)
166
+
167
+
168
+ def render_markdown(result: dict, repo: str) -> str:
169
+ if not result["runs"]:
170
+ return f"### StackVitals CI health: {_md(repo)}\n\nNo completed CI runs in the last {result['window_days']} days."
171
+ g = _groups(result)
172
+ out = [f"### StackVitals CI health: {_md(repo)}",
173
+ f"{result['runs']} runs in {result['window_days']} days, {result['rerun_share']:.0%} re-run, "
174
+ f"queue median {_dur(result['queue']['median_s'])}.", ""]
175
+ if bench := _bench(result, _md):
176
+ out += [bench[0]] + [f"- {line}" for line in bench[1]] + [""]
177
+ tg = _test_groups(result)
178
+ if tg["flaky"]:
179
+ out += ["| State | Test | Flakes | Fails with |", "|---|---|---|---|"]
180
+ out += [f"| {STATE_TEXT[t['state']]} | {_md(t['id'])} | {t['flake_events']} of {t['runs']} runs | {_md(t.get('message') or '-')} |"
181
+ for t in tg["flaky"][:10]] + [""]
182
+ out += ["| State | Job | Flakes | Most failing step | Lost to re-runs |", "|---|---|---|---|---|"]
183
+ out += [f"| {STATE_TEXT[j['state']]} | {_md(_wf(j['workflow']))} / {_md(j['job'])} | {j['flake_events']} of {j['runs']} runs "
184
+ f"| {_md(j['top_step'] or '-')} | {j['retry_minutes']:.0f} min |" for j in g["flaky"][:10]] or ["| - | no flaky jobs | | | |"]
185
+ big = [x for x in result.get("failure_groups") or [] if x["tests"] > 1]
186
+ if big:
187
+ x = big[0]
188
+ out.append(f"Biggest failure group: same failure in {x['tests']} tests ({x['failures']} failures): "
189
+ f"{_md(x['type'])} at {_md(x['where'] or '-')}.\n")
190
+ for j in g["broken"]:
191
+ out.append(f"\nKeeps failing: **{_md(_wf(j['workflow']))} / {_md(j['job'])}** ({j['failures']} of {j['runs']} runs).")
192
+ for j in g["slower"][:5]:
193
+ out.append(f"\nGetting slower: **{_md(_wf(j['workflow']))} / {_md(j['job'])}** "
194
+ f"{_dur(j['slower']['before_s'])} -> {_dur(j['slower']['after_s'])}.")
195
+ return "\n".join(out)
196
+
197
+
198
+ CSS = shell.CSS
199
+
200
+
201
+ MONITOR_TEXT = {"pass_on_retry": "pass on retry", "failure_rate": "failure rate", "broken": "keeps failing"}
202
+
203
+
204
+ def _why(t: dict) -> str:
205
+ """Which monitors hold the test in its state, its labels, since when and any mute, in plain words."""
206
+ parts = []
207
+ for m in t.get("monitors") or []:
208
+ text = MONITOR_TEXT.get(m, m)
209
+ if m == "failure_rate" and t.get("failure_rate") is not None:
210
+ text += f" {t['failure_rate']:.0%}"
211
+ parts.append(text)
212
+ parts += [x.replace("_", " ") for x in t.get("labels") or []]
213
+ if t.get("since"):
214
+ parts.append(f"since {t['since'][:10]}")
215
+ if t.get("decision"):
216
+ parts.append(decided_text(t))
217
+ elif t.get("muted_until"):
218
+ parts.append(f"muted until {t['muted_until']}")
219
+ return ", ".join(parts)
220
+
221
+
222
+ DECIDED = {"quarantine": "quarantined", "unquarantine": "kept out of quarantine", "mute": "muted", "unmute": "unmuted",
223
+ "acknowledge": "acknowledged", "reopen": "reopened"}
224
+
225
+
226
+ def decided_text(item: dict) -> str:
227
+ """Who decided what about a test or job, until when and why, in plain words."""
228
+ d = item.get("decision") or {}
229
+ if not d:
230
+ return ""
231
+ return (f"{DECIDED.get(d.get('verb'), d.get('verb'))} by {d.get('by') or '?'}" + (f" until {d['until']}" if d.get("until") else "")
232
+ + (f": {d['reason']}" if d.get("reason") else ""))
233
+
234
+
235
+ def _groups_html(result: dict, repo: str) -> str:
236
+ e = html.escape
237
+ groups = [g for g in result.get("failure_groups") or [] if g["tests"] > 1 or g["failures"] > 1]
238
+ if not groups:
239
+ return ""
240
+ rows = []
241
+ for g in groups[:20]:
242
+ ex = (g.get("examples") or [{}])[0]
243
+ link = (f"<a href='https://github.com/{e(repo)}/actions/runs/{int(ex['run'])}/attempts/{int(ex['att'])}'>latest</a>"
244
+ if ex.get("run") and int(ex["run"]) > 0 else "")
245
+ rows.append(f"<tr><td>{e(g['type'] or '-')}</td><td><code>{e(g['where'] or '-')}</code></td><td>{e(g['message'] or '')}</td>"
246
+ f"<td>{g['tests']} tests</td><td>{g['failures']} in {g['runs']} runs</td>"
247
+ f"<td>{e((g['first'] or '')[:10])} to {e((g['last'] or '')[:10])}</td><td>{link}</td></tr>")
248
+ return ("<h2>Failure groups</h2><p class=mut>Failures with one cause: the same exception raised at the same place in your "
249
+ "code, across tests and runs. Fix the cause once instead of each test.</p><div class=tbl><table class=find><tr>"
250
+ "<th data-filter>Error</th><th>Where</th><th>Message</th><th>Tests</th><th>Failures</th><th>Seen</th><th>Run</th></tr>"
251
+ + "".join(rows) + "</table></div>")
252
+
253
+
254
+ def _seen(item: dict, repo: str) -> str:
255
+ # The last-seen date links to the newest run that proves the flake.
256
+ day = html.escape((item.get("last_flake") or "")[:10])
257
+ ev = (item.get("evidence") or [])[-1:]
258
+ if not ev or not day:
259
+ return day
260
+ url = f"https://github.com/{html.escape(repo)}/actions/runs/{int(ev[0]['run'])}/attempts/{int(ev[0]['att'])}"
261
+ return f"<a href='{url}'>{day}</a>"
262
+
263
+
264
+ def detail_name(kind: str, ident: str) -> str:
265
+ """The file name of one test's or job's history page: readable, and unique through a short hash."""
266
+ slug = re.sub(r"[^a-z0-9]+", "-", ident.lower()).strip("-")[-60:].strip("-")
267
+ return f"{kind}-{slug}-{hashlib.sha1(ident.encode('utf-8')).hexdigest()[:8]}.html"
268
+
269
+
270
+ def _name(kind: str, ident: str, label: str, result: dict) -> str:
271
+ # Linked to its history page when one is written (items that are not ok).
272
+ if ident in ((result.get("history") or {}).get(kind + "s") or {}):
273
+ return f"<a href='history/{detail_name(kind, ident)}'>{html.escape(label)}</a>"
274
+ return html.escape(label)
275
+
276
+
277
+ def _owners_cell(item: dict) -> str:
278
+ # Plain text on the page: naming people here notifies nobody.
279
+ where = html.escape(item.get("owners_from") or "")
280
+ who = html.escape(", ".join(item.get("owners") or [])) or "-"
281
+ return f"<td title='CODEOWNERS for {where}'>{who}</td>"
282
+
283
+
284
+ def _owners_note(item: dict) -> str:
285
+ if not item.get("owners"):
286
+ return ""
287
+ return (f"<p>Owners: {html.escape(', '.join(item['owners']))} "
288
+ f"<span class=mut>(CODEOWNERS for {html.escape(item['owners_from'])})</span></p>")
289
+
290
+
291
+ def _trends(result: dict) -> str:
292
+ weeks = result.get("weekly") or []
293
+ if len(weeks) < 2:
294
+ return ""
295
+ labels = [w["week"] for w in weeks]
296
+ flaky = shell.chart("Flaky share per week", "Week", labels, [("Job runs that flaked", [w["flake_share"] for w in weeks])],
297
+ y="% of job runs", fmt=lambda v: f"{v:g}%")
298
+ waits = shell.chart("Push to all checks done, per week (median)", "Week", labels,
299
+ [("Minutes", [None if w["push_median_s"] is None else round(w["push_median_s"] / 60, 1) for w in weeks])],
300
+ y="minutes", fmt=lambda v: f"{v:g} min")
301
+ return ("<h2>Trends</h2><p class=mut>Every week of the kept history. Push waits leave out pushes that needed a re-run, "
302
+ "like the comparison with other repos.</p>" + shell.charts([flaky, waits]))
303
+
304
+
305
+ def render_html(result: dict, repo: str, pages=()) -> str:
306
+ e = html.escape
307
+ sub = f"Last {result['window_days']} days, updated {e(result['generated'])}"
308
+ wrap = lambda body: shell.page("CI health", repo, "ci.html", sub, body, pages) # noqa: E731
309
+ if not result["runs"]:
310
+ return wrap(f"<p>No completed CI runs in the last {result['window_days']} days.</p>")
311
+ g, m = _groups(result), result["minutes"]
312
+ cards = [("Runs", result["runs"]), ("Re-run share", f"{result['rerun_share']:.0%}"),
313
+ ("Flaky jobs", sum(j["state"] == "flaky" for j in result["jobs"])),
314
+ ("Lost to re-runs", f"{sum(j['retry_minutes'] for j in g['flaky']):.0f} min"),
315
+ ("Queue median", _dur(result["queue"]["median_s"])), ("Minutes (all runners)", sum(m.values()))]
316
+ body = ["<div class=cards>" + "".join(f"<div class=card><span class=mut>{e(k)}</span><b>{e(str(v))}</b></div>"
317
+ for k, v in cards) + "</div>",
318
+ ]
319
+ body.append(_trends(result))
320
+ if bench := _bench(result, e):
321
+ body.append(f"<h2>Compared with similar repos</h2><p class=mut>{bench[0]}</p><ul>"
322
+ + "".join(f"<li>{line}</li>" for line in bench[1]) + "</ul>")
323
+ tg = _test_groups(result)
324
+ if result.get("has_test_reports"):
325
+ body.append("<h2>Flaky tests</h2>")
326
+ if tg["flaky"] or tg["broken"]:
327
+ named = any("owners" in t for t in tg["flaky"] + tg["broken"])
328
+ body.append("<div class=tbl><table class=find><tr><th data-filter>State</th><th>Test</th><th>Flakes</th><th>Flake rate</th>"
329
+ "<th>Why</th><th>Fails with</th><th>Last seen</th>" + ("<th>Owners</th>" if named else "") + "</tr>")
330
+ for t in tg["flaky"] + tg["broken"]:
331
+ body.append(f"<tr><td><span class='tag {t['state']}'>{e(STATE_TEXT[t['state']])}</span></td><td>{_name('test', t['id'], t['id'], result)}</td>"
332
+ f"<td>{t['flake_events']} of {t['runs']}</td><td>{t['flake_rate']:.0%} "
333
+ f"<span class=mut>({t['rate_low']:.0%}-{t['rate_high']:.0%})</span></td>"
334
+ f"<td>{e(_why(t))}</td><td>{e(t.get('message') or '-')}</td><td>{_seen(t, repo)}</td>"
335
+ + (_owners_cell(t) if named else "") + "</tr>")
336
+ body.append("</table></div>")
337
+ else:
338
+ body.append("<p>No flaky tests found.</p>")
339
+ body.append(_groups_html(result, repo))
340
+ else:
341
+ body.append(f"<p class=mut>{e(NO_REPORTS)}</p>")
342
+ body.append("<h2>Flaky jobs</h2><p class=mut>Failed, then passed with no code change: on a re-run, or on another run of the same code.</p>")
343
+ if g["flaky"]:
344
+ named = any("owners" in j for j in g["flaky"])
345
+ body.append("<div class=tbl><table class=find><tr><th data-filter>State</th><th>Job</th><th>Flakes</th><th>Flake rate</th>"
346
+ "<th>Most failing step</th><th>Lost</th><th>Last seen</th>" + ("<th>Owners</th>" if named else "") + "</tr>")
347
+ for j in g["flaky"]:
348
+ body.append(f"<tr><td><span class='tag {j['state']}'>{e(STATE_TEXT[j['state']])}</span></td>"
349
+ f"<td>{_name('job', j['workflow'] + '|' + j['job'], _wf(j['workflow']) + ' / ' + j['job'], result)}"
350
+ + (f"<br><span class=mut>{e(decided_text(j))}</span>" if j.get("decision") else "") + "</td>"
351
+ f"<td>{j['flake_events']} of {j['runs']}</td>"
352
+ f"<td>{j['flake_rate']:.0%} <span class=mut>({j['rate_low']:.0%}-{j['rate_high']:.0%})</span></td>"
353
+ f"<td>{e(j['top_step'] or '-')}</td><td>{j['retry_minutes']:.0f} min</td><td>{_seen(j, repo)}</td>"
354
+ + (_owners_cell(j) if named else "") + "</tr>")
355
+ body.append("</table></div>")
356
+ else:
357
+ body.append("<p>No flaky jobs found.</p>")
358
+ if g["broken"]:
359
+ body.append("<h2>Keeps failing</h2><ul>" + "".join(
360
+ f"<li>{_name('job', j['workflow'] + '|' + j['job'], _wf(j['workflow']) + ' / ' + j['job'], result)}: "
361
+ f"{j['failures']} of {j['runs']} runs failed</li>" for j in g["broken"]) + "</ul>")
362
+ if g["slower"]:
363
+ body.append("<h2>Getting slower</h2><ul>" + "".join(
364
+ f"<li>{e(_wf(j['workflow']))} / {e(j['job'])}: {_dur(j['slower']['before_s'])} &rarr; {_dur(j['slower']['after_s'])} "
365
+ f"(+{j['slower']['pct']}%) in the last 7 days</li>" for j in g["slower"]) + "</ul>")
366
+ body.append("<h2>Slowest jobs</h2><div class=tbl><table class=find><tr><th>Job</th><th>Median</th><th>95th percentile</th><th>Runs</th></tr>"
367
+ + "".join(f"<tr><td>{e(_wf(j['workflow']))} / {e(j['job'])}</td><td>{_dur(j['median_s'])}</td>"
368
+ f"<td>{_dur(j['p95_s'])}</td><td>{j['runs']}</td></tr>" for j in g["slowest"]) + "</table></div>")
369
+ if tg["slow"]:
370
+ body.append("<h2>Slowest tests</h2><div class=tbl><table class=find><tr><th>Test</th><th>Median</th><th>95th percentile</th><th>Change</th></tr>"
371
+ + "".join(f"<tr><td>{e(t['id'])}</td><td>{_ms(t['median_ms'])}</td><td>{_ms(t['p95_ms'])}</td>"
372
+ f"<td>{'+' + str(t['slower']['pct']) + '% last 7 days' if t['slower'] else '-'}</td></tr>"
373
+ for t in tg["slow"]) + "</table></div>")
374
+ cov = result.get("coverage")
375
+ if cov and cov.get("latest"):
376
+ lt = cov["latest"]
377
+ change = "" if cov.get("change") is None else f", {cov['change'] * 100:+.1f} points over the window"
378
+ body.append(f"<h2>Coverage</h2><p>Line coverage on the default branch: {lt['pct']:.1%} ({lt['covered']} of {lt['lines']} "
379
+ f"lines, {e(lt['at'][:10])}{change}).</p>")
380
+ if cov.get("prs"):
381
+ body.append("<div class=tbl><table class=find><tr><th>Pull request</th><th>Added lines that ran</th></tr>" + "".join(
382
+ f"<tr><td><a href='https://github.com/{e(repo)}/pull/{int(p['pr'])}'>#{int(p['pr'])}</a></td>"
383
+ f"<td>{'-' if p['pct'] is None else format(p['pct'], '.0%')} ({p['covered']} of {p['lines']})</td></tr>"
384
+ for p in cov["prs"][:30]) + "</table></div>")
385
+ if result.get("new_tests"):
386
+ body.append("<h2>New tests, last 14 days</h2><div class=tbl><table class=find><tr><th>Test</th><th>First seen</th>"
387
+ "<th>Runs since</th><th>Failed</th><th>State</th></tr>" + "".join(
388
+ f"<tr><td>{e(t['id'])}</td><td>{e(t['first'][:10])}</td><td>{t['runs']}</td><td>{t['failures']}</td>"
389
+ f"<td>{e(t['state'])}</td></tr>" for t in result["new_tests"]) + "</table></div>")
390
+ if result.get("skipped_tests"):
391
+ body.append("<h2>Skipped on the default branch</h2><div class=tbl><table class=find><tr><th>Test</th><th>Since</th>"
392
+ "<th>For</th><th>Jobs</th></tr>" + "".join(
393
+ f"<tr><td>{e(t['id'])}{' (newly skipped)' if t['new'] else ''}</td><td>{e(t['since'][:10])}</td>"
394
+ f"<td>{t['days']} days</td><td>{t.get('suites', 1)}</td></tr>" for t in result["skipped_tests"])
395
+ + "</table></div>")
396
+ body.append(f"<footer class=mut>Minutes: linux {m['linux']}, windows {m['windows']}, macOS {m['macos']}, other {m['other']}. "
397
+ "History is kept in this repo's <code>stackvitals-data</code> branch. Made by StackVitals.</footer>")
398
+ return wrap("".join(body))
399
+
400
+
401
+ FLAKE_TEXT = {"retry": "passed on a re-run", "flip": "the same code passed in another run",
402
+ "in_job": "passed on a retry inside the job"}
403
+ MAX_ROWS = 300
404
+
405
+
406
+ def render_detail(kind: str, ident: str, item: dict, rows: list, result: dict, repo: str, pages=()) -> str:
407
+ """One test's or job's page: its runs over the kept history, newest first, flakes marked and every run linked."""
408
+ e = html.escape
409
+ label = ident if kind == "test" else f"{_wf(item.get('workflow'))} / {item.get('job')}"
410
+ sub = (f"<span class='tag {e(item['state'])}'>{e(STATE_TEXT[item['state']])}</span> {item['flake_events']} flakes in "
411
+ f"{item['runs']} runs over the last {result['window_days']} days &middot; <a href='../ci.html'>back to CI health</a>")
412
+ body = [_owners_note(item), f"<p class=mut>Fails with: {e(item['message'])}</p>" if item.get("message") else ""]
413
+ runs, flakes = Counter(week_of(r[0]) for r in rows), Counter(week_of(r[0]) for r in rows if r[4])
414
+ first = min(runs, default=None)
415
+ weeks = [w["week"] for w in result.get("weekly") or [] if first and w["week"] >= first]
416
+ if len(weeks) >= 2:
417
+ body.append(shell.charts([shell.chart("Runs and flakes per week", "Week", weeks,
418
+ [("Runs", [runs[w] for w in weeks]), ("Flakes", [flakes[w] for w in weeks])],
419
+ y="runs", kind="bar")]))
420
+ newest = sorted(rows, key=lambda r: r[0], reverse=True)
421
+ # The newest runs, then older flakes, so a page stays small even for a job that flakes all the time.
422
+ keep = newest[:MAX_ROWS] + [r for r in newest[MAX_ROWS:] if r[4]][:MAX_ROWS]
423
+ body.append("<h2>Runs</h2><div class=tbl><table class=find><tr><th>When</th><th data-filter>Result</th><th>Run</th>"
424
+ "<th>Branch</th><th>Event</th></tr>")
425
+ for when, run, att, passed, flake, branch, event in keep:
426
+ cell = (f"<span class='tag flaky'>flaky</span> <span class=mut>{e(FLAKE_TEXT.get(flake, flake))}</span>" if flake
427
+ else "passed" if passed else "<span class='tag broken'>failed</span>")
428
+ url = f"https://github.com/{e(repo)}/actions/runs/{int(run)}/attempts/{int(att)}"
429
+ body.append(f"<tr><td class=nw>{e(when[:16].replace('T', ' '))}</td><td>{cell}</td><td><a href='{url}'>{int(run)}</a></td>"
430
+ f"<td>{e(branch or '-')}</td><td>{e(event or '-')}</td></tr>")
431
+ body.append("</table></div>")
432
+ if len(keep) < len(rows):
433
+ body.append(f"<p class=mut>Showing the newest {MAX_ROWS} runs and up to {MAX_ROWS} older flakes; "
434
+ f"{len(rows) - len(keep)} older runs left out.</p>")
435
+ return shell.page(label, repo, None, sub, "".join(body), pages, root="../", app_route=f"ci/{kind}/{quote(label, safe='')}")
@@ -0,0 +1,67 @@
1
+ """`stackvitals ci retry`: re-run only the tests that failed, inside the same job (Datadog's auto test retries), and never
2
+ the ones the data branch calls broken: a test that fails every time is a real failure, and a retry would only hide it.
3
+
4
+ stackvitals ci retry --reports 'out/*.xml' -- pytest --junitxml out/r.xml --then pytest --lf --junitxml out/r.xml
5
+
6
+ The first command runs the suite; when it fails, the reports name what failed. If any of those is known broken, the job
7
+ fails at once and says so; otherwise the retry command (your runner's own "only failures": pytest --lf, jest
8
+ --onlyFailures, rspec --only-failures) runs up to --times times, and the job passes when a retry does."""
9
+ import argparse
10
+ import json
11
+ import subprocess
12
+ import sys
13
+
14
+ from stackvitals.ci.burnin import _read
15
+
16
+
17
+ def broken_tests(ref: str = "origin/stackvitals-data", run=subprocess.run) -> set[str]:
18
+ """Tests the data branch's published docs/data.json calls broken (failing every time on the default branch)."""
19
+ out = run(["git", "show", f"{ref}:docs/data.json"], capture_output=True)
20
+ if out.returncode != 0:
21
+ return set()
22
+ try:
23
+ return {t["id"] for t in json.loads(out.stdout).get("tests") or [] if t.get("state") == "broken"}
24
+ except ValueError:
25
+ return set()
26
+
27
+
28
+ def retry(first: list[str], again: list[str], report_globs: list[str], times: int, broken: set[str],
29
+ run=subprocess.run) -> tuple[int, dict]:
30
+ """(exit code, what happened): 0 when the suite or a retry passed, the suite's code otherwise."""
31
+ code = run(first).returncode
32
+ if code == 0:
33
+ return 0, {"retried": 0, "failed": []}
34
+ report = _read(report_globs) or {"failed": []}
35
+ failed = sorted({f[0] for f in report["failed"]})
36
+ known = sorted(set(failed) & broken)
37
+ if known or not failed:
38
+ why = f"known broken, not retried: {', '.join(known[:5])}" if known else "no test report named a failed test"
39
+ return code, {"retried": 0, "failed": failed, "why": why}
40
+ for n in range(1, times + 1):
41
+ if run(again).returncode == 0:
42
+ return 0, {"retried": n, "failed": failed, "passed_on_retry": failed}
43
+ return code, {"retried": times, "failed": failed, "why": "still failing after the retries"}
44
+
45
+
46
+ def main(argv: list[str] | None = None) -> int:
47
+ ap = argparse.ArgumentParser(prog="stackvitals ci retry", description=__doc__.split("\n\n")[0])
48
+ ap.add_argument("--reports", action="append", default=[], required=True, help="glob of the report files the suite writes")
49
+ ap.add_argument("--times", type=int, default=1, help="retries at most (default 1)")
50
+ ap.add_argument("command", nargs=argparse.REMAINDER, help="-- the suite command --then the retry command")
51
+ a = ap.parse_args(argv)
52
+ cmd = a.command[1:] if a.command[:1] == ["--"] else a.command
53
+ if "--then" not in cmd:
54
+ print("Give the suite command, then --then and the retry command.", file=sys.stderr)
55
+ return 2
56
+ i = cmd.index("--then")
57
+ code, out = retry(cmd[:i], cmd[i + 1:], a.reports, max(1, min(a.times, 5)), broken_tests())
58
+ if out.get("passed_on_retry"):
59
+ print(f"Passed on retry {out['retried']}: {', '.join(out['passed_on_retry'][:10])} failed, then passed (flaky).")
60
+ elif out.get("why"):
61
+ print(f"Not passed: {out['why']}.", file=sys.stderr)
62
+ return code
63
+
64
+
65
+ if __name__ == "__main__":
66
+ sys.exit(main())
67
+
@@ -0,0 +1,102 @@
1
+ """Verified self-healing CI: a failure that breaks the default branch every run (not a flake) gets one fix request, an
2
+ issue with the evidence an agent or a person needs and a definition of done; StackVitals closes it with proof once the
3
+ tests pass there again, and reopens it if they break again. It never pushes code: a fix is someone's pull request."""
4
+ import re
5
+
6
+ from stackvitals.ci.api import APIError
7
+ from stackvitals.ci.investigate import prompt
8
+
9
+ LABEL = "stackvitals-fix"
10
+ NEW_PER_RUN = 2 # fix requests opened in one run; the rest wait for the next
11
+ COPILOT = "copilot-swe-agent[bot]" # GitHub's coding agent, when the team turns on fix_by_copilot
12
+ MARK = re.compile(r"<!-- stackvitals-fix:(\S+) tests=([^>]*?) -->")
13
+
14
+
15
+ def _title(g: dict) -> str:
16
+ return f"CI broken: {g.get('type') or 'failure'} at {g.get('where') or 'an unknown place'}"[:200]
17
+
18
+
19
+ def _broken(result: dict) -> list[tuple[dict, list[str]]]:
20
+ """Failure groups whose tests fail every recent run on the default branch, with those tests (muted ones left out)."""
21
+ state = {t["id"]: t for t in result.get("tests") or []}
22
+ out = []
23
+ for g in result.get("failure_groups") or []:
24
+ hit = [tid for tid in g.get("top_tests") or [] if (state.get(tid) or {}).get("state") == "broken"
25
+ and not state[tid].get("muted_until")]
26
+ if hit:
27
+ out.append((g, hit))
28
+ return out
29
+
30
+
31
+ def _body(repo: str, g: dict, tests: list[str], result: dict, branch: str) -> str:
32
+ done = "\n".join(f"- `{t}`" for t in tests)
33
+ return "\n".join([
34
+ prompt(repo, g, result.get("tests") or []),
35
+ "",
36
+ f"**Done when** these tests pass on {branch} again; StackVitals then closes this issue with the runs that show it:",
37
+ done,
38
+ "",
39
+ "Opened by StackVitals because these tests fail every recent run on the default branch (not a flake). StackVitals "
40
+ "never pushes code: a fix is a pull request someone (or a coding agent assigned here) opens and a person merges.",
41
+ f"<!-- stackvitals-fix:{g['id']} tests={','.join(tests)} -->",
42
+ ])
43
+
44
+
45
+ def _all(api) -> dict[str, dict]:
46
+ found = {}
47
+ for page in range(1, 4):
48
+ batch = api._get(f"/repos/{api.repo}/issues", {"labels": LABEL, "state": "all", "per_page": 100, "page": page}) or []
49
+ for issue in batch:
50
+ m = MARK.search(issue.get("body") or "")
51
+ if "pull_request" not in issue and m and issue["number"] > found.get(m.group(1), {}).get("number", 0):
52
+ found[m.group(1)] = {**issue, "_tests": [t for t in m.group(2).split(",") if t]}
53
+ if len(batch) < 100:
54
+ break
55
+ return found
56
+
57
+
58
+ def sync_fix_requests(api, result: dict, assign: str | None = None, branch: str | None = None) -> dict:
59
+ """Open, update, verify-and-close and reopen fix requests; `assign="copilot"` also assigns GitHub's coding agent."""
60
+ branch = branch or result.get("default_branch") or "the default branch"
61
+ issues = _all(api)
62
+ counts = {"created": 0, "updated": 0, "verified": 0, "reopened": 0, "kept_closed": 0, "assigned": 0, "held": 0}
63
+ broken = _broken(result)
64
+ for g, tests in broken:
65
+ issue, body = issues.get(g["id"]), _body(api.repo, g, tests, result, branch)
66
+ if issue and issue.get("state") == "open":
67
+ api.send("PATCH", f"/repos/{api.repo}/issues/{issue['number']}", {"body": body})
68
+ counts["updated"] += 1
69
+ elif issue and issue.get("state_reason") not in (None, "completed", "reopened"):
70
+ counts["kept_closed"] += 1 # the team decided not to fix it here
71
+ elif issue:
72
+ api.send("PATCH", f"/repos/{api.repo}/issues/{issue['number']}", {"state": "open", "body": body})
73
+ api.send("POST", f"/repos/{api.repo}/issues/{issue['number']}/comments",
74
+ {"body": f"Broken again on {branch}: {', '.join(f'`{t}`' for t in tests)} fail every recent run."})
75
+ counts["reopened"] += 1
76
+ elif counts["created"] >= NEW_PER_RUN:
77
+ counts["held"] += 1
78
+ else:
79
+ new = {"title": _title(g), "body": body, "labels": [LABEL]}
80
+ if assign == "copilot":
81
+ try:
82
+ api.send("POST", f"/repos/{api.repo}/issues", {**new, "assignees": [COPILOT]})
83
+ counts["assigned"] += 1
84
+ except APIError:
85
+ # The agent isn't enabled here, or the token can't assign it: the request still stands.
86
+ api.send("POST", f"/repos/{api.repo}/issues", new)
87
+ else:
88
+ api.send("POST", f"/repos/{api.repo}/issues", new)
89
+ counts["created"] += 1
90
+ still = {g["id"] for g, _t in broken}
91
+ state = {t["id"]: t.get("state") for t in result.get("tests") or []}
92
+ for gid, issue in issues.items():
93
+ if issue.get("state") != "open" or gid in still or not issue["_tests"]:
94
+ continue
95
+ # Verified only when every test it named is measured again and none is broken or flaky.
96
+ if all(state.get(t) not in (None, "broken", "flaky", "suspect") for t in issue["_tests"]):
97
+ api.send("POST", f"/repos/{api.repo}/issues/{issue['number']}/comments",
98
+ {"body": f"Fixed: {', '.join(f'`{t}`' for t in issue['_tests'])} pass again on {branch} in the latest "
99
+ "runs StackVitals read. Closing; it reopens if they break again."})
100
+ api.send("PATCH", f"/repos/{api.repo}/issues/{issue['number']}", {"state": "closed", "state_reason": "completed"})
101
+ counts["verified"] += 1
102
+ return counts