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,574 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import time
8
+ from datetime import datetime, timedelta, timezone
9
+ from pathlib import Path
10
+
11
+ from stackvitals.ci.analyze import SHOWN, analyze
12
+ from stackvitals.ci.api import ActionsAPI, APIError, OutOfBudget
13
+ from stackvitals.ci import bench
14
+ from stackvitals.ci.automation import (alert_message, apply_decisions, new_flakes, owners_of, quarantine_list, rerun_failed_jobs, send_alert,
15
+ should_rerun, sync_issues)
16
+ from stackvitals.ci.collect import PR_EVENTS, collect
17
+ from stackvitals.ci.feedback import (MARKER, classify, find_pr, our_comment, quarantine_note, quarantined_failures,
18
+ remembered, render_comment, upsert_comment, verdict_payload, blamed_sha)
19
+ from stackvitals.ci.owners import attach_owners, read_codeowners, test_files
20
+ from stackvitals.ci.report import detail_name, render_detail, render_html, render_markdown
21
+ from stackvitals.ci.store import DataStore
22
+ from stackvitals.ci.triage import MAX_CALLS, UNDECIDED, triage
23
+ from stackvitals.ci.triggers import pr_edit_workflows, pr_metadata_workflows
24
+ from stackvitals.ci.monitors import transitions
25
+ from stackvitals.core.store import published
26
+ from stackvitals.core.summary import write_ci_summary
27
+ from stackvitals import shell
28
+
29
+ BRANCH = "stackvitals-data"
30
+ LABEL = "CI Health"
31
+ GREEN = {"success", "neutral", "skipped"}
32
+ FEEDBACK_OVERLAP = timedelta(minutes=15)
33
+ MAX_PRS, MAX_RERUNS, MAX_LOOKUPS, AFTER_COLLECT = 20, 5, 30, 60
34
+ BOT = ("github-actions[bot]", "41898282+github-actions[bot]@users.noreply.github.com")
35
+ DATA_README = """# StackVitals data
36
+
37
+ This branch holds CI history collected by StackVitals: `ci/days/` (one file per day), `ci/state.json`,
38
+ and the dashboard in `docs/`. To publish the dashboard (public repos), set GitHub Pages to deploy from
39
+ this branch, folder `/docs`. Nothing here is sent anywhere else.
40
+ """
41
+
42
+
43
+ class GitError(RuntimeError):
44
+ pass
45
+
46
+
47
+ class DataBranch:
48
+ # A shallow clone of the data branch; the remote URL may carry a token, so it is never printed.
49
+ def __init__(self, remote: str, branch: str, path: Path):
50
+ self.remote, self.branch, self.path = remote, branch, Path(path)
51
+
52
+ def _git(self, *args, cwd=None, check=True) -> subprocess.CompletedProcess:
53
+ # Git for Windows can turn on a file watcher for every repo; a short-lived clone must not leave one running.
54
+ env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_CONFIG_COUNT": "1",
55
+ "GIT_CONFIG_KEY_0": "core.fsmonitor", "GIT_CONFIG_VALUE_0": "false"}
56
+ run = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, env=env)
57
+ if check and run.returncode:
58
+ raise GitError(f"git {args[0]} failed: {run.stderr.replace(self.remote, '<remote>')[-300:]}")
59
+ return run
60
+
61
+ def _network(self, *args, cwd=None) -> subprocess.CompletedProcess:
62
+ # Network steps can hiccup ("could not read from remote"); try three times before giving up.
63
+ for attempt in range(3):
64
+ run = self._git(*args, cwd=cwd, check=False)
65
+ if run.returncode == 0:
66
+ return run
67
+ if self.path.exists() and args[0] == "clone":
68
+ shutil.rmtree(self.path, ignore_errors=True)
69
+ time.sleep(2 * (attempt + 1))
70
+ raise GitError(f"git {args[0]} failed: {run.stderr.replace(self.remote, '<remote>')[-300:]}")
71
+
72
+ def open(self) -> None:
73
+ if self.path.exists():
74
+ shutil.rmtree(self.path)
75
+ heads = self._network("ls-remote", "--heads", self.remote, self.branch)
76
+ if heads.stdout.strip():
77
+ self._network("clone", "-q", "--depth", "1", "--branch", self.branch, "--single-branch", self.remote,
78
+ str(self.path))
79
+ return
80
+ # First run: an empty branch with no shared history with the code.
81
+ self.path.mkdir(parents=True)
82
+ self._git("init", "-q", cwd=self.path)
83
+ self._git("checkout", "-q", "--orphan", self.branch, cwd=self.path)
84
+ self._git("remote", "add", "origin", self.remote, cwd=self.path)
85
+
86
+ def dirty(self) -> bool:
87
+ self._git("add", "-A", cwd=self.path)
88
+ return bool(self._git("diff", "--cached", "--quiet", cwd=self.path, check=False).returncode)
89
+
90
+ def squash(self, message: str) -> str:
91
+ """Rewrite the branch to one commit holding the current files; refused if anyone pushed in between."""
92
+ expected = self._git("rev-parse", "HEAD", cwd=self.path).stdout.strip()
93
+ self._git("add", "-A", cwd=self.path)
94
+ self._git("checkout", "-q", "--orphan", "stackvitals-squash", cwd=self.path)
95
+ self._git("-c", f"user.name={BOT[0]}", "-c", f"user.email={BOT[1]}", "commit", "-q", "-m", message, cwd=self.path)
96
+ push = self._git("push", "-q", f"--force-with-lease=refs/heads/{self.branch}:{expected}", "origin",
97
+ f"HEAD:refs/heads/{self.branch}", cwd=self.path, check=False)
98
+ if push.returncode == 0:
99
+ return "pushed"
100
+ if "rejected" in push.stderr or "stale info" in push.stderr:
101
+ return "rejected"
102
+ raise GitError(f"git push failed: {push.stderr.replace(self.remote, '<remote>')[-300:]}")
103
+
104
+ def commit_push(self, message: str) -> str:
105
+ if not self.dirty():
106
+ return "unchanged"
107
+ self._git("-c", f"user.name={BOT[0]}", "-c", f"user.email={BOT[1]}", "commit", "-q", "-m", message, cwd=self.path)
108
+ for attempt in range(3):
109
+ push = self._git("push", "-q", "origin", f"HEAD:refs/heads/{self.branch}", cwd=self.path, check=False)
110
+ if push.returncode == 0:
111
+ return "pushed"
112
+ # The server said no (protected branch, push rule, hook): trying again can't help.
113
+ if "[remote rejected]" in push.stderr and "cannot lock ref" not in push.stderr:
114
+ raise GitError(f"the server refused the push to {self.branch} (a protected branch, push rule or hook, or "
115
+ f"a token that can't write): {push.stderr.replace(self.remote, '<remote>')[-300:]}")
116
+ # Someone else pushed first: the caller starts again on top of their data.
117
+ if "rejected" in push.stderr or "fetch first" in push.stderr or "non-fast-forward" in push.stderr:
118
+ return "rejected"
119
+ time.sleep(2 * (attempt + 1))
120
+ raise GitError(f"git push failed: {push.stderr.replace(self.remote, '<remote>')[-300:]}")
121
+
122
+
123
+ def _own_workflow(env: dict) -> str | None:
124
+ # GITHUB_WORKFLOW_REF looks like owner/repo/.github/workflows/name.yml@refs/heads/main.
125
+ ref = env.get("GITHUB_WORKFLOW_REF") or ""
126
+ repo = env.get("GITHUB_REPOSITORY") or ""
127
+ return ref.split("@")[0][len(repo) + 1:] if ref.startswith(repo + "/") else None
128
+
129
+
130
+ def write_outputs(root: Path, result: dict, repo: str, site: str = "docs", data_readme: bool = True) -> None:
131
+ docs = root / site
132
+ docs.mkdir(parents=True, exist_ok=True)
133
+ # The header links only the module pages already written, so a link never leads nowhere.
134
+ pages = shell.present(docs) | {"ci.html"}
135
+ write_details(docs, result, repo, pages)
136
+ # index.html is the app now (core/webapp.py); this is the printable CI page.
137
+ (docs / "ci.html").write_text(render_html(result, repo, pages), encoding="utf-8")
138
+ # The per-run history goes only into the pages; the public data file stays small.
139
+ out = {k: v for k, v in result.items() if k != "history"}
140
+ from stackvitals.ci.investigate import prompt
141
+ # A prompt to hand an AI agent for the biggest failure groups; the app copies it.
142
+ out["failure_groups"] = [{**g, "prompt": prompt(repo, g, result.get("tests") or [])} if k < PROMPTS else g
143
+ for k, g in enumerate(result.get("failure_groups") or [])]
144
+ (docs / "data.json").write_text(json.dumps(published(out), indent=1), encoding="utf-8")
145
+ (docs / ".nojekyll").write_text("", encoding="utf-8")
146
+ (docs / "badge.json").write_text(json.dumps(bench.badge(result.get("pushes"))), encoding="utf-8")
147
+ if data_readme:
148
+ (root / "README.md").write_text(DATA_README, encoding="utf-8")
149
+
150
+
151
+ PROMPTS = 10
152
+ DETAIL_FILE = re.compile(r"(job|test)-[a-z0-9-]*-[0-9a-f]{8}\.html")
153
+
154
+
155
+ def write_details(docs: Path, result: dict, repo: str, pages) -> None:
156
+ """A history page per test or job that is not ok; pages of items that are ok again are removed (only ours)."""
157
+ folder = docs / "history"
158
+ if folder.is_dir():
159
+ for old in folder.iterdir():
160
+ if DETAIL_FILE.fullmatch(old.name):
161
+ old.unlink()
162
+ history = result.get("history") or {}
163
+ items = [("job", f"{j['workflow']}|{j['job']}", j) for j in result.get("jobs") or []]
164
+ items += [("test", t["id"], t) for t in result.get("tests") or []]
165
+ for kind, ident, item in items:
166
+ rows = (history.get(kind + "s") or {}).get(ident)
167
+ if rows:
168
+ folder.mkdir(exist_ok=True)
169
+ (folder / detail_name(kind, ident)).write_text(render_detail(kind, ident, item, rows, result, repo, pages),
170
+ encoding="utf-8")
171
+
172
+
173
+ def add_owners(api, store, result: dict, ref: str | None, today: str) -> None:
174
+ """Owners for what the dashboard lists, from CODEOWNERS (a few requests a day); never stops a run."""
175
+ tests = [t["id"] for t in result.get("tests") or [] if t["state"] in SHOWN]
176
+ if not tests and not any(j["state"] in SHOWN for j in result.get("jobs") or []):
177
+ return
178
+ try:
179
+ co = read_codeowners(api, ref, store, today)
180
+ attach_owners(result, co, test_files(api, ref, store, today, tests) if co["text"] and tests else {})
181
+ except (APIError, OutOfBudget):
182
+ pass
183
+
184
+
185
+ def _trigger(env: dict) -> dict | None:
186
+ # The finished CI run that started this one (workflow_run trigger), if any.
187
+ if env.get("GITHUB_EVENT_NAME") != "workflow_run" or not env.get("GITHUB_EVENT_PATH"):
188
+ return None
189
+ try:
190
+ return json.loads(Path(env["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8"))
191
+ except (OSError, ValueError):
192
+ return None
193
+
194
+
195
+ def _latest(data: dict) -> dict:
196
+ latest = {}
197
+ for r in data["runs"]:
198
+ if r["att"] >= latest.get(r["id"], {}).get("att", 0):
199
+ latest[r["id"]] = r
200
+ return latest
201
+
202
+
203
+ def _finished_since(latest: dict, old: dict | None, trigger_id) -> list[dict]:
204
+ # GitHub cancels queued runs of a busy concurrency group, so each run also covers what finished since the last.
205
+ ids = {trigger_id} if trigger_id else set()
206
+ if old and old.get("generated"):
207
+ cut = datetime.fromisoformat(old["generated"].replace("Z", "+00:00")) - FEEDBACK_OVERLAP
208
+ ids |= {rid for rid, r in latest.items() if (r.get("up") or "") >= cut.strftime("%Y-%m-%dT%H:%M:%SZ")}
209
+ return [latest[i] for i in sorted(ids) if i in latest]
210
+
211
+
212
+ def _same_pr(r: dict, seed: dict, pr: int) -> bool:
213
+ if r.get("ev") not in PR_EVENTS:
214
+ return False
215
+ if pr in (r.get("pr") or []):
216
+ return True
217
+ return not r.get("pr") and bool(r.get("ho")) and (r.get("ho"), r.get("br")) == (seed.get("ho"), seed.get("br"))
218
+
219
+
220
+ def _pr_of(api, r: dict) -> int | None:
221
+ if r.get("pr"):
222
+ return r["pr"][0]
223
+ if not r.get("ho") or not r.get("br"):
224
+ return None
225
+ found = api._get(f"/repos/{api.repo}/pulls", {"state": "open", "head": f"{r['ho']}:{r['br']}"})
226
+ return found[0]["number"] if found else None
227
+
228
+
229
+ def _patch_of(data: dict, run_ids: set) -> dict | None:
230
+ """The widest patch coverage measured in these runs (matrix jobs measure the same code)."""
231
+ found = [t["patch"] for t in data.get("tests") or [] if t["run"] in run_ids and t.get("patch")]
232
+ return max(found, key=lambda p: p.get("lines") or 0) if found else None
233
+
234
+
235
+ def _triage_on(settings: dict | None, private: bool = False, notes: list | None = None) -> bool:
236
+ t = ((settings or {}).get("ci") or {}).get("triage") or {}
237
+ if t.get("provider", "off") == "off" or not os.environ.get("STACKVITALS_TRIAGE_KEY"):
238
+ return False
239
+ if private and not t.get("allow_private"):
240
+ if notes is not None and not any("ci.triage" in n for n in notes):
241
+ notes.append("Model triage is off for this private repo: set [ci.triage] allow_private = true once your "
242
+ "provider's terms suit you (free tiers may keep what they're sent).")
243
+ return False
244
+ return True
245
+
246
+
247
+ def _pr_files(api, repo: str, pr: int) -> list[str]:
248
+ try:
249
+ return [f["filename"] for f in api._get(f"/repos/{repo}/pulls/{pr}/files", {"per_page": 100}) or []]
250
+ except (APIError, OutOfBudget):
251
+ return []
252
+
253
+
254
+ def _pr_feedback(recent: list[dict], latest: dict, payload: dict | None, api, data: dict, result: dict,
255
+ repo: str, quarantined: set | None = None, comments: bool = True, verdict: bool = False,
256
+ notes: list | None = None, min_patch: float = 0.0, logs: dict | None = None,
257
+ settings: dict | None = None, private: bool = False, decided: dict | None = None) -> str | None:
258
+ # One comment per PR about every failing workflow on its newest code; a passing PR only edits an old comment.
259
+ trigger = (payload or {}).get("workflow_run") or {}
260
+ prs, looked = {}, {}
261
+ for r in recent:
262
+ if r.get("ev") not in PR_EVENTS or len(prs) >= MAX_PRS:
263
+ continue
264
+ if r["id"] == trigger.get("id"):
265
+ pr = find_pr(api, payload)
266
+ elif r.get("pr"):
267
+ pr = r["pr"][0]
268
+ elif (r.get("ho"), r.get("br")) in looked or len(looked) < MAX_LOOKUPS:
269
+ # Fork PR runs carry no PR number: one lookup per head owner and branch, and only a few per run.
270
+ key = (r.get("ho"), r.get("br"))
271
+ pr = looked[key] if key in looked else looked.setdefault(key, _pr_of(api, r))
272
+ else:
273
+ pr = None
274
+ if pr:
275
+ prs.setdefault(pr, r)
276
+ outcomes = []
277
+
278
+ def post_verdict(sha: str, code: list[dict], items: list[dict], held: bool = False) -> None:
279
+ try:
280
+ # Cancelled, timed out or never started blocks too; only green passes (known flakes are re-run, never let through).
281
+ stopped = [{"kind": "run", "name": r.get("wfn") or "CI", "verdict": r.get("c")} for r in code
282
+ if r.get("c") not in GREEN and r.get("c") != "failure"]
283
+ running = [w for w in (api._get(f"/repos/{repo}/actions/runs", {"head_sha": sha, "per_page": 50}) or {})
284
+ .get("workflow_runs") or [] if w.get("status") != "completed" and w.get("path") != own]
285
+ patch = _patch_of(data, {r["id"] for r in code})
286
+ api.send("POST", f"/repos/{repo}/check-runs",
287
+ {**verdict_payload(items + stopped, all(r.get("c") in GREEN for r in code), len(running), patch, min_patch,
288
+ held),
289
+ "head_sha": sha})
290
+ except (APIError, OutOfBudget) as exc:
291
+ if notes is not None:
292
+ notes.append(f"Could not post the StackVitals verdict check ({exc}); it needs `checks: write`.")
293
+ own = (os.environ.get("GITHUB_WORKFLOW_REF") or "").split("@")[0].split("/", 2)[-1] or None
294
+ strict = verdict and bool(((settings or {}).get("automation") or {}).get("verdict_strict"))
295
+ calls_left = MAX_CALLS
296
+ for pr, seed in prs.items():
297
+ mine = [r for r in latest.values() if _same_pr(r, seed, pr)] or [seed]
298
+ newest = max(mine, key=lambda r: r.get("cr") or "")["sha"]
299
+ code = [r for r in mine if r["sha"] == newest]
300
+ items = [i for r in code if r.get("c") == "failure"
301
+ for i in classify(api, data, result, r["id"], logs)]
302
+ found = None
303
+ if comments and items and _triage_on(settings, private, notes):
304
+ # Advice only: the verdict check below is posted from the same verdicts either way. Views already in our
305
+ # comment are reused, and one run makes at most MAX_CALLS calls across all its pull requests.
306
+ found = our_comment(api, pr)
307
+ read = {jid: sec for r in code for jid, sec in ((logs or {}).get(r["id"]) or {}).items()}
308
+ needs = any(i["verdict"] in UNDECIDED and str(i.get("job_id")) not in remembered((found or {}).get("body"))
309
+ for i in items)
310
+ calls_left -= triage(items, read, _pr_files(api, repo, pr) if needs and calls_left else [], settings,
311
+ os.environ.get("STACKVITALS_TRIAGE_KEY"), private=private,
312
+ memory=remembered((found or {}).get("body")), limit=calls_left)
313
+ held = False
314
+ if strict and not items and all(r.get("c") in GREEN for r in code):
315
+ # Strict mode: the comment blamed this very commit on the change, and CI went green only on a re-run.
316
+ found = found or our_comment(api, pr)
317
+ if blamed_sha((found or {}).get("body")) == newest:
318
+ ok = (decided or {}).get(f"rerun:{pr}") or {}
319
+ held = not (ok.get("verb") == "accept" and (ok.get("at") or "") >= ((found or {}).get("updated_at") or ""))
320
+ if verdict:
321
+ post_verdict(newest, code, items, held)
322
+ if not comments:
323
+ continue
324
+ if held:
325
+ note = (f"{MARKER}\n### StackVitals: a re-run turned this green\nA failure on this commit looked like the change's own "
326
+ "and passed when run again. A green re-run doesn't prove the change clean, so this repo's verdict waits: "
327
+ "after a look, comment `/stackvitals accept rerun`.\n<!-- stackvitals:blamed " + newest + " -->")
328
+ outcomes.append(upsert_comment(api, pr, note, found))
329
+ continue
330
+ if items:
331
+ outcomes.append(upsert_comment(api, pr, render_comment(items, repo, newest), found))
332
+ elif all(r.get("c") in GREEN for r in code):
333
+ let_through = quarantined_failures(data, [r["id"] for r in code], quarantined)
334
+ if let_through:
335
+ # Green only because quarantined tests were let through: say so, or a real failure could hide there.
336
+ note = quarantine_note(let_through, repo, result, datetime.now(timezone.utc))
337
+ outcomes.append(upsert_comment(api, pr, note))
338
+ continue
339
+ existing = [c for c in api._get(f"/repos/{repo}/issues/{pr}/comments", {"per_page": 100}) or []
340
+ if MARKER in (c.get("body") or "")]
341
+ if existing:
342
+ outcomes.append(upsert_comment(api, pr, f"{MARKER}\n### StackVitals: CI is passing now\nThe latest run passed."))
343
+ if verdict:
344
+ # A merge queue tests its own commit, so a required verdict must be posted there too.
345
+ queued = {r["sha"] for r in recent if r.get("ev") == "merge_group" and r.get("sha")}
346
+ for sha in sorted(queued)[:MAX_PRS]:
347
+ code = [r for r in latest.values() if r.get("sha") == sha and r.get("ev") == "merge_group"]
348
+ post_verdict(sha, code, [i for r in code if r.get("c") == "failure"
349
+ for i in classify(api, data, result, r["id"], logs)])
350
+ return "created" if "created" in outcomes else "updated" if outcomes else None
351
+
352
+
353
+ def _previous(root: Path) -> dict | None:
354
+ # Last run's findings, to alert only on what newly turned flaky.
355
+ try:
356
+ return json.loads((root / "docs" / "data.json").read_text(encoding="utf-8"))
357
+ except (OSError, ValueError):
358
+ return None
359
+
360
+
361
+ def ci_step(ws, env: dict, now: datetime, days: int = 90, window_days: int = 30, retention_days: int = 180,
362
+ budget: int = 800, exclude: list[str] | None = None, make_api=ActionsAPI, tests: bool = True,
363
+ artifact_pattern: str | None = None, pr_comments: bool = True, rerun_flaky: bool = False,
364
+ quarantine: bool = False, issues: bool = False, webhook: str | None = None, mention_owners: bool = False,
365
+ assign_owners: bool = False, verdict_check: bool = False) -> dict:
366
+ """CI Health's part of a job: collect into the shared checkout, analyze, write the pages; what needs the commit
367
+ first (re-runs, comments, issues, alerts, the summary) is registered as the job's after-commit hook."""
368
+ repo, token = ws.repo, env.get("STACKVITALS_TOKEN") or env.get("GITHUB_TOKEN")
369
+ skip = [p for p in [_own_workflow(env), *(exclude or [])] if p]
370
+ payload = _trigger(env)
371
+ store = DataStore(ws.path)
372
+ # Collection may spend the budget; the repo lookup comes first and a little is kept for feedback.
373
+ api = make_api(token, repo, max_requests=max(budget - AFTER_COLLECT, 1))
374
+ info = ws.cache.get("repo_info") or api._get(f"/repos/{repo}")
375
+ stats = collect(api, store, now, days=days, exclude=skip, tests=tests,
376
+ **({"artifact_pattern": artifact_pattern} if artifact_pattern else {}))
377
+ api.max_requests = budget
378
+ today = now.date().isoformat()
379
+ store.compact(today, retention_days)
380
+ data = store.load()
381
+ ws.cache["ci"] = data
382
+ meta = pr_metadata_workflows(api, store, data, info.get("default_branch"), today)
383
+ settings, setting_notes = ws.cache.get("settings"), []
384
+ if settings is None:
385
+ # Run on its own (no job-wide read): the same reader, its notes in CI's own summary.
386
+ from stackvitals.core.settings import load
387
+ got = load(api, ws.path, now)
388
+ settings, setting_notes = got.cfg, got.notes
389
+ ws.cache["settings"] = settings
390
+ status_file = ws.path / "ci" / "status.json"
391
+ try:
392
+ previous_status = json.loads(status_file.read_text(encoding="utf-8"))
393
+ except (OSError, ValueError):
394
+ previous_status = {}
395
+ result = analyze(data, now, window_days, default_branch=info.get("default_branch"), pr_metadata=meta, attempts=True,
396
+ pr_edits=pr_edit_workflows(store),
397
+ config=settings, previous=previous_status)
398
+ # Each test's status with the day it began, and one line per change, so "flaky since" survives every run.
399
+ status, changes = transitions(previous_status, result.get("tests") or [], now)
400
+ status_file.write_text(json.dumps(status, indent=1, sort_keys=True), encoding="utf-8")
401
+ if changes:
402
+ log = ws.path / "ci" / "transitions" / f"{today}.ndjson"
403
+ log.parent.mkdir(parents=True, exist_ok=True)
404
+ with log.open("a", encoding="utf-8", newline="\n") as f:
405
+ f.writelines(json.dumps(c, separators=(",", ":")) + "\n" for c in changes)
406
+ from stackvitals.ci.cost import report as cost_report
407
+ # What CI costs in minutes and money over the last 30 days, and what flaky re-runs add.
408
+ result["cost"] = cost_report(data, now, private=bool(info.get("private")), days=30)
409
+ from stackvitals.ci import impact
410
+ from stackvitals.flow.store import FlowStore
411
+ # Test impact: learned from past PRs' changed files and real failures, and replayed on them before anyone trusts it.
412
+ if (ws.path / "flow").is_dir():
413
+ pr_paths = {p["n"]: p.get("paths") or [] for p in FlowStore(ws.path).load().get("closed") or [] if p.get("paths")}
414
+ hist = impact.history_from(data, pr_paths, {t["id"] for t in result.get("tests") or [] if t.get("state") == "flaky"})
415
+ files = sorted({f for _c, failed in hist for f in failed} | {p for paths in pr_paths.values() for p in paths if impact.TEST.search(p)})
416
+ result["impact"] = impact.backtest(hist, files) if hist else None
417
+ (ws.path / "ci" / "impact.json").write_text(json.dumps({"learned": impact.learn(hist), "backtest": result["impact"]},
418
+ sort_keys=True), encoding="utf-8")
419
+ from stackvitals.ci.cost import flaky_tests
420
+ # Which flaky tests cost the most re-run minutes, and how many PRs each got in the way of.
421
+ result["cost"]["tests"] = flaky_tests(data, result.get("tests") or [], private=bool(info.get("private")))
422
+ for t in result.get("tests") or []:
423
+ t.pop("_attempts", None)
424
+ # Comparisons come from a public file only (none before launch): no extra request otherwise.
425
+ result["benchmark"] = bench.compare(result["pushes"], bench.load(None, ws.work / "bench"),
426
+ info.get("language"), info.get("stargazers_count"))
427
+ share_doc = None
428
+ if ((settings or {}).get("benchmarks") or {}).get("share"):
429
+ from stackvitals.ci import share
430
+ # The id file goes into this run's commit, so next month's figures replace this month's.
431
+ share_doc = share.figures(result["pushes"], info.get("language"), info.get("stargazers_count"),
432
+ share.share_id(ws.path), now, repo if settings["benchmarks"].get("name") else None)
433
+ shared = bench.compare(result["pushes"], bench.load(None, ws.work / "bench", name=bench.SHARED_FILE,
434
+ url_env="STACKVITALS_SHARED_BENCH_URL", default_url=bench.SHARED_URL),
435
+ info.get("language"), info.get("stargazers_count"), everyone="repos that shared their figures")
436
+ # Shared peers resemble a private team's work more than the crawl's sample, unless only they are a loose fit.
437
+ if shared and (not shared["fit"] or not result["benchmark"] or result["benchmark"]["fit"]):
438
+ result["benchmark"] = {**shared, "group": f"shared: {shared['group']}", "peers": bench.SHARED_ARE}
439
+ add_owners(api, store, result, info.get("default_branch"), today)
440
+ # What people decided on GitHub (quarantine, mute, acknowledge) shapes the list, the alerts, the issues and the page.
441
+ from stackvitals.core.decisions import Store as Decisions
442
+ decided = Decisions(ws.path).state(now.date())
443
+ apply_decisions(result, decided)
444
+ feedback = Decisions(ws.path).feedback()
445
+ if feedback:
446
+ from stackvitals.core.decisions import verdict_accuracy
447
+ # How often this team confirmed what StackVitals said, per verdict: the repo's own running accuracy (counts only).
448
+ result["verdict_feedback"] = verdict_accuracy(feedback)
449
+ old = _previous(ws.path)
450
+ latest = _latest(data)
451
+ recent = _finished_since(latest, old, ((payload or {}).get("workflow_run") or {}).get("id"))
452
+ # Failed jobs' logs, read once per run, show outside failures (a registry or the runner) the history can't.
453
+ logs = {}
454
+ to_rerun = []
455
+ if rerun_flaky:
456
+ # Decided and remembered before the commit, so overlapping runs never re-run a run twice.
457
+ done = store.state().get("reran", [])
458
+ for r in recent:
459
+ # Only first attempts can be re-run, so only their logs are read, and only until enough runs are picked.
460
+ if len(to_rerun) >= MAX_RERUNS or r.get("c") != "failure" or r["id"] in done or r["att"] != 1:
461
+ continue
462
+ if should_rerun(classify(api, data, result, r["id"], logs), r["att"]):
463
+ to_rerun.append(r["id"])
464
+ if to_rerun:
465
+ store.save_state({**store.state(), "reran": (done + to_rerun)[-200:]})
466
+ from stackvitals.ci.tickets import sync as sync_tickets
467
+ # A Jira or Linear ticket per flaky test, when the team asked for one; before the commit, so what was filed is kept.
468
+ tickets = sync_tickets(ws.path, result, settings or {}, env)
469
+ qfile = ws.path / "ci" / "quarantine.json"
470
+ if quarantine:
471
+ qfile.write_text(json.dumps(quarantine_list(result, settings, decided, now)), encoding="utf-8")
472
+ from stackvitals.ci.automation import expired
473
+ # Flaky tests past [ci.quarantine] max_days fail builds again; the page lists them as overdue for a fix.
474
+ result["quarantine_expired"] = expired(result, settings, now)
475
+ elif qfile.exists():
476
+ # Turning quarantine off must stop it at once, so the old list is removed.
477
+ qfile.unlink()
478
+ write_outputs(ws.path, result, repo)
479
+ write_ci_summary(ws.path, result, now)
480
+ fresh = new_flakes(old, result)
481
+ out = {"collect": stats, "flaky": sum(j["state"] == "flaky" for j in result["jobs"]), "comment": None, "rerun": False,
482
+ "new_flaky": fresh}
483
+
484
+ def after_commit():
485
+ notes = list(setting_notes)
486
+ # Re-runs go first: they are already recorded as done, so nothing after the commit may stop them.
487
+ for rid in to_rerun:
488
+ try:
489
+ rerun_failed_jobs(api, rid)
490
+ out["rerun"] = True
491
+ notes.append(f"Run {rid} failed only on known flakes or outside failures, so its failed jobs were re-run once.")
492
+ except (APIError, OutOfBudget) as exc:
493
+ notes.append(f"Could not re-run run {rid} ({exc}); add `actions: write` to the workflow permissions.")
494
+ if verdict_check and info.get("private") and (env.get("STACKVITALS_EVERY_CI_RUN") or "").lower() != "true":
495
+ # The workflow starts no job for a passing CI run on a private repo, so the check would wait for the next
496
+ # 3-hourly run to turn green.
497
+ notes.append("The verdict check is on, but CI runs that pass don't start StackVitals on this private repo: set the "
498
+ "repository variable STACKVITALS_EVERY_CI_RUN to true so the check passes as soon as CI does.")
499
+ if pr_comments or verdict_check:
500
+ try:
501
+ out["comment"] = _pr_feedback(recent, latest, payload, api, data, result, repo,
502
+ set(quarantine_list(result, settings, decided, now)) if quarantine else None,
503
+ comments=pr_comments, verdict=verdict_check, notes=notes,
504
+ min_patch=((settings or {}).get("ci") or {}).get("coverage", {}).get("min_patch", 0.0),
505
+ logs=logs, settings=settings, private=bool(info.get("private")),
506
+ decided=decided)
507
+ except (APIError, OutOfBudget) as exc:
508
+ notes.append(f"Could not comment on pull requests ({exc}); add `pull-requests: write` to the workflow permissions.")
509
+ if issues:
510
+ try:
511
+ counts = sync_issues(api, result, mention_owners=mention_owners, assign_owners=assign_owners,
512
+ linear=bool((((settings or {}).get("connectors") or {}).get("linear") or {}).get("teams")))
513
+ notes.append(f"Flaky-test issues: {counts['created']} opened, {counts['updated']} updated, {counts['closed']} closed, "
514
+ f"{counts['reopened']} reopened, {counts['kept_closed']} left closed as not planned.")
515
+ except (APIError, OutOfBudget) as exc:
516
+ notes.append(f"Could not update flaky-test issues ({exc}); add `issues: write` to the workflow permissions.")
517
+ if tickets.get("tickets"):
518
+ notes.append(f"{tickets['tickets'].title()} tickets: {len(tickets.get('opened') or [])} opened, "
519
+ f"{len(tickets.get('closed') or [])} closed" + (f"; {tickets['problem']}" if tickets.get("problem") else "") + ".")
520
+ auto = (settings or {}).get("automation") or {}
521
+ if auto.get("fix_requests"):
522
+ from stackvitals.ci.selfheal import sync_fix_requests
523
+ try:
524
+ fixes = sync_fix_requests(api, result, assign="copilot" if auto.get("fix_by_copilot") else None,
525
+ branch=info.get("default_branch"))
526
+ out["fix_requests"] = fixes
527
+ notes.append(f"Fix requests: {fixes['created']} opened ({fixes['assigned']} assigned to Copilot), "
528
+ f"{fixes['verified']} verified fixed and closed, {fixes['reopened']} reopened, {fixes['updated']} "
529
+ f"updated" + (f", {fixes['held']} waiting for the next run" if fixes["held"] else "") + ".")
530
+ except (APIError, OutOfBudget) as exc:
531
+ notes.append(f"Could not update fix requests ({exc}); add `issues: write` to the workflow permissions.")
532
+ from stackvitals.core import chat
533
+ # Each owning team with its own webhook hears about its tests; the rest go to the repo's webhook.
534
+ for hook, names in chat.route(owners_of(fresh, result), {**env, "STACKVITALS_WEBHOOK": webhook or ""}).items():
535
+ app = chat.app_url(repo, info, "ci")
536
+ links = [("Open CI health", app)] if app else [("Workflow runs", f"https://github.com/{repo}/actions")]
537
+ # The data is committed by now; a chat service being down must not fail the run, only be reported.
538
+ failed = send_alert(hook, alert_message(names, repo, result, links))
539
+ if failed:
540
+ notes.append(failed)
541
+ summary = env.get("GITHUB_STEP_SUMMARY")
542
+ sharing = (settings or {}).get("benchmarks") or {}
543
+ if sharing.get("share"):
544
+ # Opt-in: a link a person opens to file this month's rounded figures; the Action itself sends nothing.
545
+ from stackvitals.ci import share
546
+ notes.append(share.summary_line(share_doc))
547
+ if summary:
548
+ note = "" if stats["complete"] else "\n\nStill filling in older history (GitHub's hourly limit); the next run continues."
549
+ with open(summary, "a", encoding="utf-8") as f:
550
+ f.write(render_markdown(result, repo) + note + "".join(f"\n\n{n}" for n in notes) + "\n")
551
+ ws.after_commit(after_commit, LABEL)
552
+ return out
553
+
554
+
555
+ def run_action(env: dict, now: datetime | None = None, days: int = 90, window_days: int = 30,
556
+ retention_days: int = 180, budget: int = 800, exclude: list[str] | None = None,
557
+ make_api=ActionsAPI, tests: bool = True, artifact_pattern: str | None = None,
558
+ pr_comments: bool = True, rerun_flaky: bool = False, quarantine: bool = False,
559
+ issues: bool = False, webhook: str | None = None, mention_owners: bool = False, workspace=None,
560
+ assign_owners: bool = False, verdict_check: bool = False) -> dict:
561
+ """One StackVitals CI run inside GitHub Actions: collect, store in the data branch, rebuild the dashboard.
562
+ With a shared workspace the job commits once for every module; alone, this does its own checkout and commit."""
563
+ from stackvitals.core.workspace import run_job
564
+ now = now or datetime.now(timezone.utc)
565
+
566
+ def step(ws):
567
+ return ci_step(ws, env, now, days, window_days, retention_days, budget, exclude, make_api, tests, artifact_pattern,
568
+ pr_comments, rerun_flaky, quarantine, issues, webhook, mention_owners, assign_owners, verdict_check)
569
+ if workspace is not None:
570
+ return step(workspace)
571
+ from stackvitals.core.settings import prepare
572
+ job = run_job(env, [(LABEL, step)], now, message=f"stackvitals: CI data {now:%Y-%m-%d %H:%M} UTC", catch=False,
573
+ prepare=prepare(env, make_api))
574
+ return {**job["results"][LABEL], "pushed": job["outcome"] == "pushed", "unchanged": job["outcome"] == "unchanged"}