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,699 @@
1
+ import json
2
+ import re
3
+ from collections import defaultdict
4
+ from importlib import resources
5
+ from datetime import timedelta
6
+
7
+ from stackvitals.ci.analyze import FAIL, _downstream, _failed_step
8
+ from stackvitals.ci.api import ActionsAPI, APIError, OutOfBudget
9
+ from stackvitals.ci.fingerprint import normalise
10
+ from stackvitals.ci.kinds import kind_of
11
+ from stackvitals.ci.report import _md, began_text
12
+ from stackvitals.ci.triggers import PR_EVENTS
13
+ from stackvitals.core.time import parse as _t
14
+
15
+ MARKER = "<!-- stackvitals:ci -->"
16
+ # How often each verdict held on labelled public CI (written by bench/accuracy/summary.py from the accuracy cards).
17
+ TRACK_RECORD = json.loads(resources.files("stackvitals.ci").joinpath("track_record.json").read_text(encoding="utf-8"))
18
+ KNOWN = {"flaky", "suspect", "fixed"}
19
+ LABEL = {"known flaky": "Likely flaky", "outside failure": "Outside failure", "flaked before": "Flaked before",
20
+ "already failing": "Already failing on main", "maybe flaky": "Maybe flaky", "new": "New", "PR details": "PR details",
21
+ "maybe yours": "Maybe yours"}
22
+ # An error about the PR itself, from a job that reads it (triggers.py): a PR template, title or label check.
23
+ PR_WORDS = re.compile(r"(?i)\bpull request\b|\bPR (?:title|description|body|template|labels?)\b|\badd\b.{0,40}\blabel"
24
+ r"|\blabels?\b.{0,40}\b(?:required|missing|needed)\b|\bmissing\b.{0,30}\blabels?\b"
25
+ # A checkbox or template alone is common in code errors; with a PR's wording beside it, it isn't.
26
+ r"|\b(?:checkbox|checklist|template)\b.{0,80}\b(?:not (?:checked|ticked|filled)|unchecked|incomplete)\b"
27
+ r"|\b(?:section|description)\b.{0,80}\b(?:checkbox|checklist|template)\b")
28
+ PR_DETAIL = ("this job checks the pull request's title, description or labels and runs again when they change: fix them "
29
+ "on the pull request; the code is not the problem and a re-run with the same details fails again")
30
+ # A registry, a cache, the network or the runner failing, as job logs say it: on 284 labelled public CI failures, none of
31
+ # the 32 whose failing step showed one was caused by the change (bench/accuracy/flaky/outside.py).
32
+ OUTSIDE = re.compile(r"(?i)(ENOTFOUND|EAI_AGAIN|ECONNRESET|ECONNREFUSED|ETIMEDOUT|socket hang up|Could not resolve host|"
33
+ r"Temporary failure in name resolution|Name or service not known|Failed to fetch|"
34
+ r"Failed to download(?! and build)|\b50[234] (Service Unavailable|Bad Gateway|Gateway Time-?out)|"
35
+ r"HTTP (error )?50[234]|status code 50[234]|TLS handshake timeout|i/o timeout|"
36
+ r"Connection (timed out|reset|refused)|ServerBusy|No space left on device|"
37
+ # Phase 15: curl's connect and timeout codes (not 22, an HTTP error a change can cause), more network
38
+ # errors; out of memory stays out (on the labels, the one match was the change's).
39
+ r"curl: \((6|7|28|35|52|56)\)|Network is unreachable|Could not connect to|SSL_ERROR_SYSCALL|"
40
+ r"Connection reset by peer|"
41
+ r"runner has received a shutdown signal|lost communication with the server|Cannot connect to the Docker daemon|"
42
+ r"failed to connect to the docker API|The hosted runner encountered an error|Unable to make request|"
43
+ r"toomanyrequests|Too Many Requests|secondary rate limit)")
44
+ # Safe to re-run needs this many flakes in the window and the failing step among theirs: on labelled public pull requests,
45
+ # 15 of 34 failures of jobs that had flaked before were caused by the change; none of those passing this rule were.
46
+ SAFE_FLAKES = 3
47
+ TEST_STEP = re.compile(r"test|pytest|jest|vitest|mocha|rspec|phpunit|gradle|mvn|maven|ctest|tox|nox|playwright|cypress",
48
+ re.I)
49
+
50
+
51
+ def _verdict(entry: dict | None) -> str:
52
+ if entry and entry["state"] in KNOWN and entry.get("flake_events", 0) + (entry["state"] == "fixed") > 0:
53
+ return "known flaky"
54
+ if entry and entry["state"] == "broken":
55
+ return "already failing"
56
+ return "new"
57
+
58
+
59
+ # A failing test is never excused, whatever it logged; pytest's "::test FAILED" counts, ninja's "FAILED:" (a build step) doesn't.
60
+ TEST_FAILED = re.compile(r"(::\S+ FAILED|FAILED \S+::|--- FAIL|AssertionError|\b\d+ (tests?|specs?|examples?) failed|"
61
+ r"\b\d+ failed\b|\b\d+ failing\b|Tests:.*\d+ failed|There were failing tests)")
62
+
63
+
64
+ def failing_section(log: str | None) -> str:
65
+ """The failing step's part of a job log: from that step's start to just after the first error (at most 80 lines),
66
+ warnings left out."""
67
+ lines = (log or "").splitlines()
68
+ first = next((i for i, x in enumerate(lines) if "##[error]" in x), len(lines))
69
+ # Logs from the API put a time before every line, so the step's start is looked for after it.
70
+ start = max((i for i in range(max(0, first - 80), first) if LOG_TIME.sub("", lines[i]).startswith("##[group]")),
71
+ default=max(0, first - 80))
72
+ return "\n".join(x for x in lines[start:first + 1] if "##[warning]" not in x)
73
+
74
+
75
+ def outside_signature(log: str | None) -> str | None:
76
+ """The outside failure the failing step's log shows, else None (and None whenever a test failed there)."""
77
+ section = failing_section(log)
78
+ m = OUTSIDE.search(section)
79
+ return m.group(0) if m and not TEST_FAILED.search(section) else None
80
+
81
+
82
+ # How flaky checks fail: a wait or deadline running out, a benchmark gate, a live service inside a test. On labelled
83
+ # public CI logs, none of the 25 failures showing one was caused by the change (bench/accuracy/flaky/maybe.py).
84
+ FLAKY_LOOK = re.compile(r"(?i)(timed? ?out (after|in|waiting)|timeout of \d+ ?ms exceeded|exceeded (the )?timeout|test timed out|"
85
+ r"deadline exceeded|TimeoutError|Timeout - Async callback|waiting for .{0,80} timed out|"
86
+ r"Expected condition failed: waiting for|Matcher did not succeed in time|"
87
+ r"benchmark.{0,60}(regress|slower|threshold)|performance regression|slower than (the )?baseline|"
88
+ r"retries \d+ exhausted|retries exhausted|max(imum)? retries (exceeded|reached)|returned HTTP 5\d\d|"
89
+ r"rate limit|quota exceeded|Service Unavailable)")
90
+
91
+
92
+ def flaky_signature(text: str | None) -> str | None:
93
+ """What in a failing step's log (or a test's message) looks like a flaky check failing, else None."""
94
+ m = FLAKY_LOOK.search(failing_section(text))
95
+ return m.group(0) if m else None
96
+
97
+
98
+ def flake_kind(text: str | None) -> str | None:
99
+ """infrastructure, network, concurrency or timing, from a failing step's log or a test's message; else None."""
100
+ return kind_of(failing_section(text))
101
+
102
+
103
+ def _with_kind(item: dict, text: str | None) -> dict:
104
+ # Only failures already read as flaky say what kind they look like; one likely from the change never does.
105
+ kind = flake_kind(text) if item["verdict"] in ("known flaky", "flaked before", "maybe flaky") else None
106
+ return {**item, "flake": kind, "detail": f"{item['detail']}; looks like a {kind} flake"} if kind else item
107
+
108
+
109
+
110
+ MAX_LOGS = 5
111
+
112
+
113
+ def failed_logs(api, data: dict, run_id: int, cache: dict | None = None, index=None) -> dict:
114
+ """The logs of a run's failed jobs in its final attempt (at most five), by job id; read once per run, and skipped
115
+ quietly when GitHub refuses or the budget runs out."""
116
+ cache = {} if cache is None else cache
117
+ if run_id in cache:
118
+ return cache[run_id]
119
+ atts = [r["att"] for r in (index.attempts.get(run_id, []) if index else (r for r in data["runs"] if r["id"] == run_id))]
120
+ out = cache[run_id] = {}
121
+ mine = index.jobs.get((run_id, max(atts)), []) if index and atts else [j for j in data["jobs"] if atts and j["run"] == run_id
122
+ and j["att"] == max(atts)]
123
+ for j in [j for j in mine if j["c"] in FAIL][:MAX_LOGS]:
124
+ try:
125
+ # Only the failing step's part is kept: that is all the verdict reads.
126
+ out[j["id"]] = failing_section(api._get(f"/repos/{api.repo}/actions/jobs/{j['id']}/logs", raw=True).decode("utf-8", "replace"))
127
+ except APIError as exc:
128
+ if "HTTP 403" in str(exc):
129
+ break # the token can't read logs here: no point asking for the rest
130
+ continue # an expired log leaves that job as it was
131
+ except OutOfBudget:
132
+ break
133
+ return out
134
+
135
+
136
+ # A pull request's failure in a step the default branch failed in this soon before was already failing there.
137
+ ALREADY_WINDOW = timedelta(days=3)
138
+
139
+
140
+ def _ago(delta: timedelta) -> str:
141
+ hours = round(delta.total_seconds() / 3600)
142
+ return "under an hour" if hours < 1 else f"{hours} hours" if hours < 48 else f"{hours // 24} days"
143
+
144
+
145
+ class Index:
146
+ """One pass over the stored history, shared by every run a job classifies: runs, jobs and reports by run, and the
147
+ default branch's failed jobs and tests (a busy repo's 180 days hold hundreds of thousands of jobs)."""
148
+
149
+ def __init__(self, data: dict, default_branch: str | None):
150
+ self.branch = default_branch
151
+ self.attempts, self.jobs, self.reports = defaultdict(list), defaultdict(list), defaultdict(list)
152
+ runs = {}
153
+ for r in data.get("runs") or []:
154
+ runs[(r["id"], r["att"])] = r
155
+ self.attempts[r["id"]].append(r)
156
+ for j in data.get("jobs") or []:
157
+ self.jobs[(j["run"], j["att"])].append(j)
158
+ for t in data.get("tests") or []:
159
+ self.reports[(t["run"], t["att"])].append(t)
160
+ on_main = lambda r: r is not None and r.get("ev") == "push" and default_branch and r.get("br") == default_branch # noqa: E731
161
+ self.main_jobs, self.main_tests = defaultdict(list), defaultdict(list)
162
+ for (rid, att), js in self.jobs.items():
163
+ r = runs.get((rid, att))
164
+ if on_main(r):
165
+ for j in js:
166
+ if j["c"] in FAIL and (t := _t(j.get("en"))):
167
+ self.main_jobs[(r.get("path"), j["n"], _failed_step(j))].append((t, rid, j["id"]))
168
+ for (rid, att), reps in self.reports.items():
169
+ r = runs.get((rid, att))
170
+ if on_main(r) and (t := _t(r.get("cr"))):
171
+ for rep in reps:
172
+ for f in rep["failed"]:
173
+ self.main_tests[f[0]].append((t, rid, f[1], f[2]))
174
+
175
+
176
+ def _on_default(run: dict, branch: str) -> bool:
177
+ # A pull request from a fork's "main" is still a pull request, never the default branch.
178
+ return run.get("br") == branch and run.get("ev") not in PR_EVENTS
179
+
180
+
181
+ def failed_on_default(data: dict, run: dict, job: dict, step: str, default_branch: str | None,
182
+ index: Index | None = None) -> dict | None:
183
+ """The newest default-branch push run whose same job failed in this step in the 3 days before this job ended."""
184
+ when = _t(job.get("en")) or _t(run.get("cr"))
185
+ if not default_branch or not when or _on_default(run, default_branch):
186
+ return None
187
+ index = index or Index(data, default_branch)
188
+ found = None
189
+ for t, rid, jid in index.main_jobs.get((run.get("path"), job["n"], step), ()):
190
+ if rid != run["id"] and timedelta(0) <= when - t <= ALREADY_WINDOW and (found is None or t > found["t"]):
191
+ found = {"run": rid, "job": jid, "t": t, "ago": _ago(when - t)}
192
+ return found
193
+
194
+
195
+ def _tests_failed_on_default(index: Index, run: dict, tids) -> dict:
196
+ """Test id -> the newest default-branch push run that failed it in the 3 days before this run, for these tests."""
197
+ when = _t(run.get("cr"))
198
+ if not index.branch or not when or _on_default(run, index.branch):
199
+ return {}
200
+ out = {}
201
+ for tid in tids:
202
+ for t, rid, fp, msg in index.main_tests.get(tid, ()):
203
+ if rid != run["id"] and timedelta(0) <= when - t <= ALREADY_WINDOW and (tid not in out or t > out[tid]["t"]):
204
+ out[tid] = {"run": rid, "t": t, "ago": _ago(when - t), "fp": fp, "msg": msg}
205
+ return out
206
+
207
+
208
+ def _same_test_failure(f: list, before: dict) -> bool:
209
+ # The fingerprint when both have one, else the message without what changes between runs.
210
+ if f[1] and before["fp"]:
211
+ return f[1] == before["fp"]
212
+ return normalise(f[2]) == normalise(before["msg"])
213
+
214
+
215
+ LOG_ERROR = re.compile(r"(?i)##\[error\]|\berror\b|\bfail|panic|exception|assert|timeout|timed out")
216
+ LOG_TIME = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z ")
217
+ # Summary lines every failing run of a tool prints: shared by any two failures, so they never count as the same error.
218
+ BOILERPLATE = re.compile(r"(?i)^\s*(##\[error\])?\s*(Test Suites:|Tests:|Snapshots:|Time:|Ran all test suites|"
219
+ r"npm ERR! (Failed at|code|errno|A complete log|This is probably|Test failed|lifecycle)|"
220
+ r"ELIFECYCLE|error Command failed with exit code|Command failed with exit code|"
221
+ r"make(\[\d+\])?: \*\*\*|Error: Process completed with exit code|=+ .*\b\d+ failed\b.* =+$|"
222
+ r"FAILED\s*$|ERROR: Job failed|error: Recipe .* failed)")
223
+ # On 24 labelled pull-request failures in a step the default branch had just failed in, the 15 sharing at least 2 of
224
+ # every 3 error lines with it were all not caused by the change; the 4 the change caused shared a third or less.
225
+ SAME_ERROR = 0.6
226
+
227
+
228
+ def error_lines(section: str | None) -> set:
229
+ """A failing step's error lines without what changes between runs (times, ids, paths, numbers)."""
230
+ lines = {normalise(LOG_TIME.sub("", x)) for x in (section or "").splitlines()
231
+ if LOG_ERROR.search(x) and "Process completed with exit code" not in x
232
+ and not BOILERPLATE.search(LOG_TIME.sub("", x))}
233
+ lines.discard("")
234
+ return lines
235
+
236
+
237
+ def error_excerpt(section: str | None, limit: int = 5) -> list[str]:
238
+ """A failing step's error lines as the log printed them (times cut), for an agent to read instead of the whole log."""
239
+ return [LOG_TIME.sub("", x).strip()[:200] for x in (section or "").splitlines()
240
+ if LOG_ERROR.search(x) and "Process completed with exit code" not in x][:limit]
241
+
242
+
243
+ def same_error(log: str | None, other: str | None) -> bool:
244
+ """Whether most of this failure's error lines appear in the other failure's too."""
245
+ mine = error_lines(log)
246
+ return bool(mine) and len(mine & error_lines(other)) / len(mine) >= SAME_ERROR
247
+
248
+
249
+ def _job_verdict(entry: dict | None, step: str) -> str:
250
+ """A job's verdict from history alone: known flaky only when it flaked often enough and in the step that failed now
251
+ (classify_run then asks the log and the pull request's files too)."""
252
+ verdict = _verdict(entry)
253
+ if verdict == "known flaky" and (entry["flake_events"] < SAFE_FLAKES or step not in (entry.get("flake_steps") or ())):
254
+ return "flaked before"
255
+ return verdict
256
+
257
+
258
+ def _test_verdict(entry: dict | None) -> str:
259
+ """A test's verdict from history alone: known flaky only when it flaked as often as a job must (the test is its own step)."""
260
+ verdict = _verdict(entry)
261
+ return "flaked before" if verdict == "known flaky" and entry["flake_events"] < SAFE_FLAKES else verdict
262
+
263
+
264
+ def named_files(text: str | None, files) -> list[str]:
265
+ """The pull request's changed files that this text names: by file name, or by a stem of 8+ characters as a word;
266
+ stems under 5 characters (main, util, index) never count."""
267
+ out = []
268
+ for f in files or ():
269
+ base = f.rsplit("/", 1)[-1]
270
+ stem = base.rsplit(".", 1)[0]
271
+ if len(stem) >= 5 and base not in out and (base in (text or "") or (
272
+ len(stem) >= 8 and re.search(r"\b" + re.escape(stem) + r"\b", text or ""))):
273
+ out.append(base)
274
+ return out
275
+
276
+
277
+ def flake_sign(text: str | None) -> str | None:
278
+ """What in a failing step's log (or a test's message) shows a flake: an outside service or the runner failing, or a
279
+ wait or live service running out, and never with a failing test beside it."""
280
+ section = failing_section(text)
281
+ if not section.strip() or TEST_FAILED.search(section):
282
+ return None
283
+ m = OUTSIDE.search(section) or FLAKY_LOOK.search(section)
284
+ return m.group(0) if m else None
285
+
286
+
287
+ def _files_text(files) -> str:
288
+ n = len(files)
289
+ return f"{n} file{'' if n == 1 else 's'}"
290
+
291
+
292
+ def _sharpen(verdict: str, text: str | None, changed: list | None, name: str = "") -> tuple[str, str | None, list]:
293
+ """History's "known flaky" or "flaked before", checked against the log and the pull request's files (pre-registered
294
+ 2026-10-28, bench/accuracy/flaky/README.md): known flaky only with a flake's sign and no changed file named; a
295
+ changed file named makes it "maybe yours". Returns the verdict, the sign and the files named."""
296
+ if verdict not in ("known flaky", "flaked before"):
297
+ return verdict, None, []
298
+ named = named_files(f"{name}\n{failing_section(text)}", changed)
299
+ if named:
300
+ return "maybe yours", None, named
301
+ sign = flake_sign(text)
302
+ if verdict == "known flaky" and not sign:
303
+ return "flaked before", None, []
304
+ return verdict, sign, []
305
+
306
+
307
+ def _detail(entry: dict | None, verdict: str, message: str | None = None, step: str | None = None,
308
+ kind: str = "job", hint: str | None = None, sign: str | None = None, named: list | None = None,
309
+ changed: list | None = None) -> str:
310
+ times = ("once" if entry["flake_events"] == 1 else f"{entry['flake_events']} times") if entry else ""
311
+ if verdict == "maybe yours":
312
+ shown = ", ".join(named[:3]) + (f" and {len(named) - 3} more" if len(named) > 3 else "")
313
+ return (f"the error names {shown}, which this pull request changed: likely this change, though it "
314
+ + (f"flaked {times} in {entry['runs']} runs in the last 30 days" if entry and entry["flake_events"]
315
+ else "was flaky until a recent fix") + "; look at the change before re-running")
316
+ if verdict == "known flaky":
317
+ last = (entry.get("last_flake") or "")[:10]
318
+ began = entry.get("began") or {}
319
+ files = f"; the error names none of the {_files_text(changed)} this pull request changed" if changed else ""
320
+ return (f"flaked {times} in {entry['runs']} runs in the last 30 days" + (f", last {last}" if last else "")
321
+ + (" in this step" if kind == "job" else "")
322
+ + f", and the {'message' if kind == 'test' else 'log'} shows a flake's sign (\"{sign}\"): looks like a flake, "
323
+ "check the error, then re-run it" + files + (f"; it {began_text(began)}" if began.get("clear") else ""))
324
+ if verdict == "flaked before" and not entry["flake_events"]:
325
+ return "flaky until a recent fix, with no flakes in the last 30 days: check whether this change caused it"
326
+ if verdict == "flaked before" and entry["flake_events"] >= SAFE_FLAKES and (kind == "test" or step in (entry.get("flake_steps") or ())):
327
+ # Often enough to look like a known flake, but nothing in this failure shows a flake.
328
+ return (f"flaked {times} in {entry['runs']} runs in the last 30 days" + (" in this step" if kind == "job" else "")
329
+ + f", but this failure {'message' if kind == 'test' else 'log'} shows no sign of a flake (an outside service, "
330
+ "the runner or a wait failing): check whether this change caused it")
331
+ if verdict == "flaked before" and kind == "test":
332
+ return (f"flaked {times} in {entry['runs']} runs in the last 30 days, too few to call it a known flake: "
333
+ "check whether this change caused it")
334
+ if verdict == "flaked before":
335
+ where = ", ".join(entry.get("flake_steps") or []) or "an unknown step"
336
+ return (f"flaked {times} in {entry['runs']} runs in the last 30 days, in {where}; this time in {step or 'an unknown step'}: "
337
+ "check whether this change caused it")
338
+ if verdict == "already failing":
339
+ return "also failing on the default branch, not caused by this change"
340
+ if verdict == "maybe flaky":
341
+ return (f"no earlier flakes, but it failed the way flaky checks often do (\"{hint}\"): re-run it once to tell; "
342
+ "if it fails again, look at this change")
343
+ return f"no earlier flakes: likely from this change" + (f" (fails with \"{message}\")" if message else "")
344
+
345
+
346
+ def classify_run(data: dict, result: dict, run_id: int, logs: dict | None = None, index: Index | None = None,
347
+ changed: list | None = None) -> list[dict]:
348
+ """What failed in the final attempt of one run, and whether history says it's flaky, broken or new; with the failed
349
+ jobs' logs (by job id), a new failure whose log shows an outside service or the runner failing says so, one that
350
+ failed the way flaky checks do reads "maybe flaky", and one with the same error as the default branch just before
351
+ reads "already failing". A known flake also needs a flake's sign in its log and no file the pull request changed
352
+ (`changed`) named there; one naming such a file reads "maybe yours". Pass the same `index` for every run of one history."""
353
+ main = result.get("default_branch")
354
+ index = index or Index(data, main)
355
+ atts = index.attempts.get(run_id)
356
+ if not atts:
357
+ return []
358
+ run = max(atts, key=lambda r: r["att"])
359
+ jobs = index.jobs.get((run_id, run["att"]), [])
360
+ reports = index.reports.get((run_id, run["att"]), [])
361
+ job_state = {(j["workflow"], j["job"]): j for j in result.get("jobs", [])}
362
+ test_state = {t["id"]: t for t in result.get("tests", [])}
363
+ groups = {g["id"]: g for g in result.get("failure_groups") or []}
364
+ pr_edits = set(result.get("pr_edit_workflows") or ())
365
+ on_main = _tests_failed_on_default(index, run, {f[0] for r in reports for f in r["failed"]}) if reports else {}
366
+ items = []
367
+ for r in reports:
368
+ for f in r["failed"]:
369
+ tid, msg = f[0], f[2]
370
+ entry = test_state.get(tid)
371
+ verdict, sign, named = _sharpen(_test_verdict(entry), msg, changed, name=tid)
372
+ hint = flaky_signature(msg) if verdict == "new" else None
373
+ verdict = "maybe flaky" if hint else verdict
374
+ item = _with_kind({"kind": "test", "name": tid, "verdict": verdict,
375
+ "detail": _detail(entry, verdict, msg, kind="test", hint=hint, sign=sign, named=named,
376
+ changed=changed)}, msg)
377
+ before = on_main.get(tid) if verdict in ("new", "flaked before", "maybe flaky", "maybe yours") else None
378
+ if before and _same_test_failure(f, before):
379
+ item |= {"verdict": "already failing", "detail": f"failed the same way on the default branch {before['ago']} "
380
+ f"before this (run {before['run']}): not caused by this change"}
381
+ elif before:
382
+ item["detail"] += f"; it failed differently on the default branch {before['ago']} before this (run {before['run']})"
383
+ if r.get("seed") and item["verdict"] != "already failing":
384
+ item["detail"] += f"; the tests ran in random order, seed {r['seed']}: replay with it"
385
+ group = groups.get(f[3]) if len(f) > 3 else None
386
+ if group and group["tests"] > 1:
387
+ # Many tests failing the same way usually share one cause: say so, so nobody fixes them one by one.
388
+ item["group"] = {k: group[k] for k in ("id", "tests", "runs", "where", "type", "first")}
389
+ item["detail"] += f"; same failure in {group['tests']} tests since {group['first'][:10]}"
390
+ items.append(item)
391
+ tests_failed = any(r["failed"] for r in reports)
392
+ for j in jobs:
393
+ if j["c"] not in FAIL or _downstream(j, jobs):
394
+ continue
395
+ base = {"kind": "job", "job_id": j["id"], "name": f"{run.get('wfn') or 'CI'} / {j['n']}"}
396
+ mine = (logs or {}).get(j["id"])
397
+ # Checked first: label edits look like flakes in a history that didn't know the job reads the PR. A job gated
398
+ # on labels by `if:` or reading the PR in a script is found by its workflow and an error about the PR.
399
+ section = failing_section(mine) if run.get("path") in pr_edits else ""
400
+ if (run.get("ev") in PR_EVENTS and section and not TEST_FAILED.search(section)
401
+ and any(PR_WORDS.search(x) for x in error_excerpt(section))):
402
+ items.append({**base, "verdict": "PR details", "detail": PR_DETAIL})
403
+ continue
404
+ entry = job_state.get((run.get("path"), j["n"]))
405
+ failing_step = _failed_step(j)
406
+ verdict, sign, named = _sharpen(_job_verdict(entry, failing_step), mine, changed)
407
+ # A job that failed with failing tests is explained by its tests, unless the job itself is known;
408
+ # a known flaky job whose test step failed is explained by its tests too, so it is never called a known flake.
409
+ if tests_failed and (verdict in ("new", "flaked before", "maybe yours")
410
+ or (verdict == "known flaky" and TEST_STEP.search(failing_step))):
411
+ continue
412
+ before = (failed_on_default(data, run, j, failing_step, main, index)
413
+ if verdict in ("new", "flaked before", "maybe yours") else None)
414
+ theirs = (logs or {}).get(before["job"]) if before else None
415
+ if before and same_error(mine, theirs):
416
+ items.append({**base, "verdict": "already failing",
417
+ "detail": f"the same error as on the default branch {before['ago']} before this (run "
418
+ f"{before['run']}), in this step: not caused by this change"})
419
+ continue
420
+ seen = outside_signature(mine) if verdict in ("new", "flaked before") else None
421
+ if seen:
422
+ items.append({**base, "verdict": "outside failure",
423
+ "detail": f"the log shows an outside service or the runner failing (\"{seen}\"): likely not this "
424
+ "change; re-run it"})
425
+ continue
426
+ hint = flaky_signature(mine) if verdict == "new" else None
427
+ verdict = "maybe flaky" if hint else verdict
428
+ item = _with_kind({**base, "verdict": verdict, "detail": _detail(entry, verdict, step=failing_step, hint=hint, sign=sign,
429
+ named=named, changed=changed)}, mine)
430
+ if before:
431
+ item["detail"] += (f"; the default branch failed in this step {before['ago']} before this (run {before['run']})"
432
+ + (", with a different error" if mine and theirs else ""))
433
+ if mine and not theirs:
434
+ item["main_job"] = before["job"] # classify() reads that log and asks again
435
+ items.append(item)
436
+ return items
437
+
438
+
439
+ def pr_files(api, index: Index, run_id: int, cache: dict) -> list | None:
440
+ """The files the run's pull request changes (first 100, one request, read once per pull request); None when the run
441
+ has no pull request or GitHub refuses."""
442
+ atts = index.attempts.get(run_id) or []
443
+ prs = max(atts, key=lambda r: r["att"]).get("pr") if atts else None
444
+ if not prs:
445
+ return None
446
+ key = ("pr_files", prs[0])
447
+ if key not in cache:
448
+ try:
449
+ got = api._get(f"/repos/{api.repo}/pulls/{prs[0]}/files", {"per_page": 100}) or []
450
+ cache[key] = [f["filename"] for f in got if isinstance(f, dict) and f.get("filename")]
451
+ except (APIError, OutOfBudget):
452
+ cache[key] = None
453
+ return cache[key]
454
+
455
+
456
+ def _job_log(api, job_id: int) -> str | None:
457
+ try:
458
+ return failing_section(api._get(f"/repos/{api.repo}/actions/jobs/{job_id}/logs", raw=True).decode("utf-8", "replace"))
459
+ except (APIError, OutOfBudget):
460
+ return None
461
+
462
+
463
+ def classify(api, data: dict, result: dict, run_id: int, cache: dict | None = None) -> list[dict]:
464
+ """classify_run with the failed jobs' logs, read once per run (the cache is shared across a job's calls), and the
465
+ log of a default-branch failure in the same step just before, read once, to tell the same error from a new one."""
466
+ cache = {} if cache is None else cache
467
+ main = result.get("default_branch")
468
+ # Built once per history and reused for every run this job classifies.
469
+ key = ("index", id(data), main, len(data.get("jobs") or []))
470
+ index = cache.get(key) or cache.setdefault(key, Index(data, main))
471
+ logs = failed_logs(api, data, run_id, cache, index)
472
+ items = classify_run(data, result, run_id, logs=logs, index=index)
473
+ # The pull request's files matter only to failures that flaked before: read them only then.
474
+ changed = pr_files(api, index, run_id, cache) if any(i["verdict"] in ("known flaky", "flaked before") for i in items) else None
475
+ if changed:
476
+ items = classify_run(data, result, run_id, logs=logs, index=index, changed=changed)
477
+ wanted = [i["main_job"] for i in items if i.get("main_job")]
478
+ if wanted:
479
+ known = cache.setdefault("default_branch_logs", {})
480
+ for jid in wanted[:MAX_LOGS]:
481
+ if jid not in known:
482
+ known[jid] = _job_log(api, jid)
483
+ items = classify_run(data, result, run_id, logs=logs | {k: v for k, v in known.items() if v}, index=index,
484
+ changed=changed)
485
+ for i in items:
486
+ i.pop("main_job", None)
487
+ return items
488
+
489
+
490
+ def _quiet(text) -> str:
491
+ # Text from logs, reports or a model: no @-mention that pings someone, no bare link that opens elsewhere.
492
+ text = re.sub(r"(?i)\b(https?):(?=//)", lambda m: m[1] + ":​", str(text))
493
+ return text.replace("@", "@​").replace("www.", "www​.")
494
+
495
+
496
+ def _code(text) -> str:
497
+ # Inside a code span a backslash escapes nothing: a backtick becomes a quote; a pipe stays escaped for the table.
498
+ return _quiet(str(text).replace("`", "'").replace("|", "\\|"))
499
+
500
+
501
+ def failure_summary(items: list[dict]) -> str:
502
+ """One line on what to do about a failed run, for an agent (MCP ci_failure)."""
503
+ verdicts = {i["verdict"] for i in items}
504
+ if not verdicts:
505
+ return "nothing failed that StackVitals could classify"
506
+ if verdicts == {"known flaky"}:
507
+ return "every failure looks like a known flake (it flaked before and its log shows a flake's sign): check the errors, then re-run the failed jobs"
508
+ if verdicts == {"PR details"}:
509
+ return "only checks on the pull request's title, description or labels failed: fix those, not the code"
510
+ if verdicts & {"new", "flaked before", "maybe yours"}:
511
+ return "some failures may be the change's: look at the ones marked new, maybe yours or flaked before first"
512
+ return ("no failure points at the change: " + "; ".join(
513
+ {"outside failure": "outside ones need a re-run", "already failing": "already failing ones fail on the default "
514
+ "branch too", "maybe flaky": "maybe flaky ones need one re-run to tell", "known flaky": "known flakes need a "
515
+ "look at the error, then a re-run", "PR details": "PR checks need the PR's details fixed"}.get(v, v)
516
+ for v in sorted(verdicts)))
517
+
518
+
519
+ # Verdicts that blame the change: in strict mode a green re-run of the same code needs a person to accept it.
520
+ BLAMED = {"new", "flaked before", "maybe yours"}
521
+ BLAMED_NOTE = re.compile(r"<!-- stackvitals:blamed ([0-9a-f]{7,40}) -->")
522
+
523
+
524
+ def blamed_sha(body: str | None) -> str | None:
525
+ """The commit our comment last blamed on the change, if any."""
526
+ m = BLAMED_NOTE.search(body or "")
527
+ return m[1] if m else None
528
+
529
+
530
+ def render_comment(items: list[dict], repo: str, sha: str | None = None) -> str:
531
+ known = sum(i["verdict"] == "known flaky" for i in items)
532
+ outside = sum(i["verdict"] == "outside failure" for i in items)
533
+ # Never "safe": held out, no rule here kept the change's own failures under 5% (bench/accuracy/flaky, 2026-10-28).
534
+ kinds = "flakes or outside failures" if outside else "flakes"
535
+ if items and known + outside == len(items):
536
+ head = (f"All {len(items)} failure(s) look like {kinds}: check the errors, then re-run them. "
537
+ "The StackVitals verdict passes once CI is green.")
538
+ elif known + outside:
539
+ head = f"{known + outside} of {len(items)} failure(s) look like {kinds}; the rest need a look."
540
+ else:
541
+ head = "None of these failures look like known flakes."
542
+ if any(i["verdict"] == "PR details" for i in items):
543
+ head += " Fix the pull request's details (title, description or labels) for the PR checks; their jobs run again on their own."
544
+ # One short row per failure; the evidence behind it and how often each verdict has held are folded below.
545
+ shown = items[:MAX_ROWS]
546
+ parts = [_clauses(i["detail"]) for i in shown]
547
+ rows = [f"| {LABEL[i['verdict']]} | {i['kind']} `{_code(i['name'])}` | {_quiet(_md(p[0]))} |" for i, p in zip(shown, parts)]
548
+ more = [f"... and {len(items) - MAX_ROWS} more."] if len(items) > MAX_ROWS else []
549
+ evidence = [f"- {LABEL[i['verdict']]} {i['kind']} `{_code(i['name'])}`: {_quiet(_md('; '.join(p[1:])))}"
550
+ for i, p in zip(shown, parts) if len(p) > 1]
551
+ held = [f"- {LABEL.get(v, v)}: {TRACK_RECORD[v]}" for v in dict.fromkeys(i["verdict"] for i in shown) if v in TRACK_RECORD]
552
+ if any(i.get("model") for i in shown):
553
+ held.append(f"- A model's view: {TRACK_RECORD['model']}")
554
+ folded = ["<details><summary>Evidence, and how often these verdicts have held</summary>", "", *evidence,
555
+ *([""] if evidence else []), "On labelled public CI failures:", *held, "", "</details>", ""]
556
+ views = {str(i["job_id"]): i["model"] for i in items if i.get("model") and i.get("job_id")}
557
+ if views:
558
+ # A dash only ever sits inside a JSON string here, so escaping it keeps any "-->" from closing the note.
559
+ folded.append("<!-- stackvitals:triage " + json.dumps(views, ensure_ascii=True).replace("-", "\\u002d") + " -->")
560
+ if sha and re.fullmatch(r"[0-9a-f]{7,40}", sha) and any(i["verdict"] in BLAMED for i in items):
561
+ folded.append(f"<!-- stackvitals:blamed {sha} -->")
562
+ return "\n".join([MARKER, "### StackVitals: about this CI failure", head, "",
563
+ "| Verdict | What failed | Why |", "|---|---|---|", *rows, *more, "", *folded,
564
+ f"<sub>From the CI history kept in {_md(repo)}'s stackvitals-data branch.</sub>"])
565
+
566
+
567
+ MAX_ROWS = 25
568
+
569
+
570
+ def _clauses(detail: str) -> list[str]:
571
+ """A detail's clauses, split at "; " outside quotes, so a quoted error message is never cut."""
572
+ out, start, quoted = [], 0, False
573
+ for i, ch in enumerate(detail):
574
+ if ch == '"':
575
+ quoted = not quoted
576
+ elif not quoted and detail.startswith("; ", i):
577
+ out.append(detail[start:i])
578
+ start = i + 2
579
+ return out + [detail[start:]]
580
+
581
+
582
+ def quarantined_failures(data: dict, run_ids, quarantined: set | None) -> list[str]:
583
+ """Quarantined tests that failed in the final attempt of these runs: they passed CI only by being let through."""
584
+ if not quarantined:
585
+ return []
586
+ final = {}
587
+ for r in data.get("runs") or []:
588
+ if r["id"] in set(run_ids):
589
+ final[r["id"]] = max(final.get(r["id"], 0), r["att"])
590
+ failed = {f[0] for t in data.get("tests") or [] if final.get(t["run"]) == t["att"] for f in t["failed"]}
591
+ return sorted(failed & set(quarantined))
592
+
593
+
594
+ LONG_QUARANTINE_DAYS = 30
595
+
596
+
597
+ def quarantine_note(tests: list[str], repo: str, result: dict | None = None, now=None) -> str:
598
+ """Says which quarantined tests were let through; with the CI result, how long each has been flaky and who owns it,
599
+ so a test left in quarantine for weeks gets noticed (owners are named without pinging them)."""
600
+ known = {t["id"]: t for t in (result or {}).get("tests") or []}
601
+ rows, old = [], []
602
+ for tid in tests[:10]:
603
+ t, extra = known.get(tid) or {}, []
604
+ since = _t(t.get("since")) if t.get("since") else None
605
+ if since and now:
606
+ days = (now - since).days
607
+ extra.append(f"flaky since {since.date().isoformat()}, {days} days")
608
+ if days > LONG_QUARANTINE_DAYS:
609
+ old.append(tid)
610
+ if t.get("owners"):
611
+ extra.append("owners " + " ".join(t["owners"]))
612
+ name = f"`{_md(tid).replace('`', '')}`" + (f" ({'; '.join(extra)})" if extra else "")
613
+ rows.append(name.replace("@", "@\u200b"))
614
+ more = f" and {len(tests) - 10} more" if len(tests) > 10 else ""
615
+ lines = [MARKER, "### StackVitals: CI passed only because of quarantine",
616
+ f"The checks passed only because these known-flaky tests are quarantined and were let through: "
617
+ f"{', '.join(rows)}{more}. They failed on this commit, so a real failure there would be hidden too."]
618
+ if old:
619
+ lines.append(f"{len(old)} of them have been flaky for over {LONG_QUARANTINE_DAYS} days: fix them, or take one out of "
620
+ f"quarantine with `/stackvitals unquarantine test:<id>` so its failures count again.")
621
+ return "\n".join(lines + ["", f"<sub>From the CI history kept in {_md(repo)}'s stackvitals-data branch.</sub>"])
622
+
623
+
624
+ def find_pr(api: ActionsAPI, payload: dict) -> int | None:
625
+ run = payload.get("workflow_run") or {}
626
+ prs = run.get("pull_requests") or []
627
+ if prs:
628
+ return prs[0]["number"]
629
+ # Fork PRs come with an empty list; find the open PR by its head owner and branch.
630
+ owner = ((run.get("head_repository") or {}).get("owner") or {}).get("login")
631
+ if not owner or not run.get("head_branch"):
632
+ return None
633
+ found = api._get(f"/repos/{api.repo}/pulls", {"state": "open", "head": f"{owner}:{run['head_branch']}"})
634
+ return found[0]["number"] if found else None
635
+
636
+
637
+ def our_comment(api: ActionsAPI, pr: int) -> dict | None:
638
+ return next((c for c in api._get(f"/repos/{api.repo}/issues/{pr}/comments", {"per_page": 100}) or []
639
+ if MARKER in (c.get("body") or "")), None)
640
+
641
+
642
+ def upsert_comment(api: ActionsAPI, pr: int, body: str, found: dict | None = None) -> str:
643
+ # One comment per PR, edited in place on every run.
644
+ c = found or our_comment(api, pr)
645
+ if c:
646
+ api.send("PATCH", f"/repos/{api.repo}/issues/comments/{c['id']}", {"body": body})
647
+ return "updated"
648
+ api.send("POST", f"/repos/{api.repo}/issues/{pr}/comments", {"body": body})
649
+ return "created"
650
+
651
+
652
+ TRIAGE_NOTE = re.compile(r"<!-- stackvitals:triage (\{.*?\}) -->", re.S)
653
+
654
+
655
+ def remembered(body: str | None) -> dict:
656
+ """The model's earlier views on this PR's failures, kept in our own comment, so one failure is sent once."""
657
+ m = TRIAGE_NOTE.search(body or "")
658
+ try:
659
+ got = json.loads(m[1]) if m else {}
660
+ except ValueError:
661
+ return {}
662
+ return got if isinstance(got, dict) else {}
663
+
664
+
665
+ VERDICT_NAME = "StackVitals verdict"
666
+
667
+
668
+ def verdict_payload(items: list[dict], green: bool, waiting: int = 0, patch: dict | None = None, min_patch: float = 0.0,
669
+ held: bool = False) -> dict:
670
+ """The check run for a pull request's newest code: success when it is green, and its added lines ran at least
671
+ `min_patch` of the time when that is set; in progress while other workflows on that commit still run, so a required
672
+ check never passes early. A likely flake never passes it by itself: on 81 held-out calls from history alone in 15
673
+ public repos, 13 were the change's own failures (16%), and the stricter rule's 4 fresh calls can't show 5%, so a re-run
674
+ decides (a flake passes, the change's fails again)."""
675
+ if waiting:
676
+ return {"name": VERDICT_NAME, "status": "in_progress",
677
+ "output": {"title": f"Waiting for {waiting} workflow{'' if waiting == 1 else 's'} to finish",
678
+ "summary": "The verdict comes once every workflow on this commit has finished."}}
679
+ blocking = [i for i in items if i.get("verdict") != "known flaky"]
680
+ if green and held:
681
+ # Strict mode: a failure that looked like the change's own went green on a re-run of the same code.
682
+ conclusion, title = "failure", "A failure that looked like this change's own passed on a re-run: comment /stackvitals accept rerun to pass"
683
+ elif green or not items:
684
+ conclusion, title = "success", "CI passed"
685
+ elif not blocking:
686
+ conclusion, title = "failure", f"Only likely flakes failed ({len(items)}): check the errors, then re-run them; this passes once CI is green"
687
+ else:
688
+ conclusion, title = "failure", f"{len(blocking)} failure{'' if len(blocking) == 1 else 's'} to look at"
689
+ summary = "\n".join(f"- {i.get('kind')} `{_code(i.get('name'))}`: {i.get('verdict')}" for i in items[:50])
690
+ if patch and patch.get("lines"):
691
+ share = patch["covered"] / patch["lines"]
692
+ line = f"Patch coverage: {share:.0%} ({patch['covered']} of {patch['lines']} added lines ran)"
693
+ if min_patch and share < min_patch:
694
+ conclusion, title = "failure", f"Patch coverage {share:.0%}, under this repo's {min_patch:.0%}" + (
695
+ f"; {title}" if title != "CI passed" else "")
696
+ line += f"; this repo asks for {min_patch:.0%} under [ci.coverage] min_patch."
697
+ summary = f"{summary}\n\n{line}".strip()
698
+ return {"name": VERDICT_NAME, "status": "completed", "conclusion": conclusion,
699
+ "output": {"title": title, "summary": summary or "Every check on the newest code passed."}}