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,120 @@
1
+ """Test shards that finish together: test files (or classes) split into N groups by their recorded times, from the
2
+ newest test reports on the default branch or from report files on this machine."""
3
+ import glob
4
+ import json
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from stackvitals.ci.api import APIError, OutOfBudget
10
+ from stackvitals.ci.collect import TEST_REPORTS
11
+ from stackvitals.ci.testreports import parse_archive, parse_report
12
+
13
+ MAX_BYTES = 50_000_000
14
+
15
+
16
+ def unit(tid: str, py_paths: bool = False) -> str:
17
+ """What a test belongs to: the first part of its id (a file, or a class); py_paths turns a dotted pytest module into
18
+ its file."""
19
+ head = tid.split("::", 1)[0]
20
+ if py_paths and "/" not in head and not head.endswith(".py"):
21
+ parts = head.split(".")
22
+ while len(parts) > 1 and parts[-1][:1].isupper():
23
+ parts.pop()
24
+ head = "/".join(parts) + ".py"
25
+ return head
26
+
27
+
28
+ def split(times: list, n: int, extra=(), py_paths: bool = False) -> list[dict]:
29
+ """[{"files", "ms"}] for n shards: the longest first, each onto the lightest shard. Files with no recorded time count
30
+ as a typical file."""
31
+ totals = {}
32
+ for tid, ms in times:
33
+ u = unit(tid, py_paths)
34
+ if not u or u.startswith("-") or any(c.isspace() for c in u):
35
+ continue # it would reach the test command as an option ("--co"), so it is never passed on
36
+ totals[u] = totals.get(u, 0) + (ms or 0)
37
+ typical = sorted(totals.values())[len(totals) // 2] if totals else 0
38
+ for f in extra:
39
+ totals.setdefault(f, typical)
40
+ shards = [{"files": [], "ms": 0} for _ in range(max(1, n))]
41
+ for u, ms in sorted(totals.items(), key=lambda x: (-x[1], x[0])):
42
+ s = min(shards, key=lambda s: (s["ms"], len(s["files"])))
43
+ s["files"].append(u)
44
+ s["ms"] += ms
45
+ for s in shards:
46
+ s["files"].sort()
47
+ return shards
48
+
49
+
50
+ def latest_times(api, branch: str, pattern: str = TEST_REPORTS, look: int = 20) -> tuple[list, dict | None]:
51
+ """Every test's time from the newest successful run on `branch` that kept test reports, and that run."""
52
+ # Push runs only: a fork's pull request from its own "main" would otherwise pass for this repo's default branch.
53
+ body = api._get(f"/repos/{api.repo}/actions/runs", {"branch": branch, "event": "push", "status": "success", "per_page": look})
54
+ for run in body.get("workflow_runs") or []:
55
+ arts = [a for a in api.artifacts(run["id"]) if not a.get("expired") and (a.get("size_in_bytes") or 0) <= MAX_BYTES
56
+ and re.search(pattern, a.get("name") or "", re.I)]
57
+ times = []
58
+ for a in arts:
59
+ data = api.download(a["id"])
60
+ try:
61
+ times += parse_archive(data)["all"]
62
+ except Exception: # an unreadable report is skipped, never the end of the command
63
+ continue
64
+ if times:
65
+ return times, run
66
+ return [], None
67
+
68
+
69
+ def _local(patterns: list[str]) -> list:
70
+ times = []
71
+ for pattern in patterns:
72
+ for name in sorted(glob.glob(pattern, recursive=True)):
73
+ parsed = parse_report(name, Path(name).read_bytes())
74
+ if parsed:
75
+ times += parsed["all"]
76
+ return times
77
+
78
+
79
+ def _dur(ms: int) -> str:
80
+ s = round(ms / 1000)
81
+ return f"{s // 60}m {s % 60:02d}s" if s >= 60 else f"{s}s"
82
+
83
+
84
+ def run(args, make_api, tok: str | None) -> int:
85
+ if args.n < 1 or args.index is not None and not 1 <= args.index <= args.n:
86
+ print("--n must be 1 or more and --index between 1 and --n.", file=sys.stderr)
87
+ return 2
88
+ source = "report files on this machine"
89
+ if args.reports:
90
+ times = _local(args.reports)
91
+ elif args.repo and tok:
92
+ api = make_api(tok, args.repo, max_requests=60)
93
+ try:
94
+ branch = api._get(f"/repos/{args.repo}").get("default_branch") or "main"
95
+ times, found = latest_times(api, branch)
96
+ except (APIError, OutOfBudget) as exc:
97
+ print(f"GitHub refused: {exc}", file=sys.stderr)
98
+ return 1
99
+ source = f"run {found['html_url']}" if found else source
100
+ else:
101
+ print("Give owner/name (with a GitHub token) or --reports 'path/*.xml'.", file=sys.stderr)
102
+ return 2
103
+ if not times:
104
+ print("No test reports with times found (a test report in an artifact whose name says test results).", file=sys.stderr)
105
+ return 1
106
+ extra = sorted({f for pattern in args.files for f in glob.glob(pattern, recursive=True)})
107
+ shards = split(times, args.n, extra, args.py_paths)
108
+ if args.index is not None:
109
+ files = shards[args.index - 1]["files"]
110
+ if not files:
111
+ # An empty argument list makes most test runners run everything, so say so where a person will see it.
112
+ print(f"Shard {args.index} is empty (fewer files than shards): skip the test command in it.", file=sys.stderr)
113
+ print(" ".join(files))
114
+ elif args.json:
115
+ print(json.dumps({"source": source, "tests": len(times), "shards": shards}, indent=1))
116
+ else:
117
+ print(f"{args.n} shards from {len(times)} tests in {sum(len(s['files']) for s in shards)} files ({source}):")
118
+ for k, s in enumerate(shards, 1):
119
+ print(f"Shard {k}, about {_dur(s['ms'])}: {' '.join(s['files'])}")
120
+ return 0
@@ -0,0 +1,59 @@
1
+ """Opt-in shared benchmarks ([benchmarks] share = true): this repo's CI figures, rounded and without its name unless
2
+ allowed, as a prefilled issue on StackVitals' public repo. Nothing is sent by the Action: a person opens the link and
3
+ files the issue. Once 10+ repos of a kind have shared, their peer group is published beside the public one."""
4
+ import json
5
+ import secrets
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from urllib.parse import urlencode
9
+
10
+ from mergescout.bench.ci import tier
11
+ from stackvitals.ci.bench import BENCH_REPO, PUBLIC_HOME
12
+
13
+ KIND, SCHEMA, MIN_PUSHES = "ci-benchmark", 1, 20
14
+ FIELDS = ("schema", "kind", "id", "month", "language", "tier", "pushes", "median_s", "p90_s", "first_try_share",
15
+ "fail_share", "rerun_share")
16
+ ID_FILE = "ci/share-id" # in the data branch
17
+
18
+
19
+ def share_id(root: Path) -> str:
20
+ """A random id kept in the data branch, so a later month replaces this repo's figures; it says nothing about the repo."""
21
+ path = Path(root) / ID_FILE
22
+ try:
23
+ found = path.read_text(encoding="utf-8").strip()
24
+ except OSError:
25
+ found = ""
26
+ if len(found) == 16 and all(c in "0123456789abcdef" for c in found):
27
+ return found
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ found = secrets.token_hex(8)
30
+ path.write_text(found + "\n", encoding="utf-8")
31
+ return found
32
+
33
+
34
+ def figures(stats: dict | None, language: str | None, stars: int | None, sid: str, now: datetime,
35
+ name: str | None = None) -> dict | None:
36
+ """The shared figures, or None with too few pushes to share safely."""
37
+ if not stats or stats.get("pushes", 0) < MIN_PUSHES:
38
+ return None
39
+ doc = {"schema": SCHEMA, "kind": KIND, "id": sid, "month": f"{now:%Y-%m}", "language": language or "other",
40
+ "tier": tier(stars), "pushes": int(round(stats["pushes"], -1)), "median_s": int(round(stats["median_s"] / 30) * 30),
41
+ "p90_s": int(round(stats["p90_s"] / 60) * 60), "first_try_share": round(stats["first_try_share"], 2),
42
+ "fail_share": round(stats["fail_share"], 2), "rerun_share": round(stats["rerun_share"], 2)}
43
+ return {**doc, "repo": name} if name else doc
44
+
45
+
46
+ def link(doc: dict) -> str:
47
+ # The issue form adds the "benchmark" label for anyone; a plain new-issue link drops labels for people without triage rights.
48
+ title = f"Benchmark figures: {doc['language']}, {doc['tier']} stars, {doc['month']}"
49
+ # Figures are filed on the public home once there is one; the daily collector reads both repos.
50
+ return f"https://github.com/{PUBLIC_HOME or BENCH_REPO}/issues/new?" + urlencode(
51
+ {"template": "benchmark.yml", "title": title, "figures": json.dumps(doc, indent=1)})
52
+
53
+
54
+ def summary_line(doc: dict | None) -> str:
55
+ if not doc:
56
+ return f"Shared benchmarks are on, but this repo had fewer than {MIN_PUSHES} pushes in the window; nothing to share yet."
57
+ who = f"named {doc['repo']}" if doc.get("repo") else "without the repo's name"
58
+ return (f"Shared benchmarks: [file this month's rounded CI figures]({link(doc)}) ({who}; the issue shows the account "
59
+ "that files it). Peer groups are published once 10+ repos of a kind have shared.")
@@ -0,0 +1,93 @@
1
+ import gzip
2
+ import json
3
+ import os
4
+ from datetime import date, timedelta
5
+ from pathlib import Path
6
+
7
+ SCHEMA = "1"
8
+ KNOWN = "tests-seen.json.gz"
9
+
10
+
11
+ class DataStore:
12
+ # History lives under <root>/ci: one NDJSON file per collection day, compressed once the day is over.
13
+ def __init__(self, root: Path):
14
+ self.dir = Path(root) / "ci"
15
+ self.days = self.dir / "days"
16
+ self.days.mkdir(parents=True, exist_ok=True)
17
+ version = self.dir / "VERSION"
18
+ if not version.exists():
19
+ version.write_text(SCHEMA + "\n", encoding="utf-8")
20
+
21
+ def add(self, records: list[dict], day: str) -> None:
22
+ if not records:
23
+ return
24
+ with (self.days / f"{day}.ndjson").open("a", encoding="utf-8", newline="\n") as f:
25
+ for r in records:
26
+ f.write(json.dumps(r, separators=(",", ":"), ensure_ascii=False) + "\n")
27
+
28
+ def _lines(self):
29
+ for path in sorted(self.days.iterdir()):
30
+ if path.name.endswith(".ndjson.gz"):
31
+ text = gzip.decompress(path.read_bytes()).decode("utf-8")
32
+ elif path.name.endswith(".ndjson"):
33
+ text = path.read_text(encoding="utf-8")
34
+ else:
35
+ continue
36
+ for line in text.splitlines():
37
+ if line.strip():
38
+ yield json.loads(line)
39
+
40
+ def load(self) -> dict[str, list[dict]]:
41
+ # Later records win, so a run seen again keeps its newest state.
42
+ runs, jobs, tests = {}, {}, {}
43
+ for r in self._lines():
44
+ if r.get("t") == "run":
45
+ runs[(r["id"], r["att"])] = r
46
+ elif r.get("t") == "job":
47
+ jobs[r["id"]] = r
48
+ elif r.get("t") == "tests":
49
+ tests[r["art"]] = r
50
+ return {"runs": list(runs.values()), "jobs": list(jobs.values()), "tests": list(tests.values())}
51
+
52
+ def state(self) -> dict:
53
+ path = self.dir / "state.json"
54
+ try:
55
+ return json.loads(path.read_text(encoding="utf-8"))
56
+ except (OSError, ValueError):
57
+ return {}
58
+
59
+ def save_state(self, state: dict) -> None:
60
+ path = self.dir / "state.json"
61
+ tmp = path.with_suffix(".tmp")
62
+ tmp.write_text(json.dumps(state, indent=1, sort_keys=True), encoding="utf-8")
63
+ os.replace(tmp, path)
64
+
65
+ def known_tests(self) -> set | None:
66
+ """Every test id seen so far, or None before the first collect that read test reports."""
67
+ try:
68
+ return set(json.loads(gzip.decompress((self.dir / KNOWN).read_bytes())))
69
+ except (OSError, ValueError, EOFError):
70
+ return None
71
+
72
+ def save_known_tests(self, ids: set) -> None:
73
+ data = gzip.compress(json.dumps(sorted(ids), separators=(",", ":")).encode("utf-8"), mtime=0)
74
+ path = self.dir / KNOWN
75
+ # Written only when a test was added, so most runs leave the data branch alone.
76
+ if not path.exists() or path.read_bytes() != data:
77
+ path.write_bytes(data)
78
+
79
+ def compress(self, today: str) -> None:
80
+ for path in sorted(self.days.glob("*.ndjson")):
81
+ if path.name.split(".")[0] < today:
82
+ # A past day is kept compressed; late re-runs are merged in and it is compressed again.
83
+ gz = path.with_name(path.name + ".gz")
84
+ old = gzip.decompress(gz.read_bytes()) if gz.exists() else b""
85
+ gz.write_bytes(gzip.compress(old + path.read_bytes(), mtime=0))
86
+ path.unlink()
87
+
88
+ def compact(self, today: str, retention_days: int = 180) -> None:
89
+ oldest = (date.fromisoformat(today) - timedelta(days=retention_days)).isoformat()
90
+ for path in sorted(self.days.iterdir()):
91
+ if path.name.split(".")[0] < oldest:
92
+ path.unlink()
93
+ self.compress(today)
@@ -0,0 +1,351 @@
1
+ import hashlib
2
+ import io
3
+ import json
4
+ import math
5
+ import re
6
+ import zipfile
7
+
8
+ from defusedxml import DefusedXmlException
9
+ from defusedxml.ElementTree import ParseError, fromstring
10
+
11
+ from stackvitals.ci import coverage
12
+ from stackvitals.ci.fingerprint import analyse
13
+
14
+ SLOW_KEEP = 200
15
+ SKIP_KEEP = 200
16
+ # Caps per report: a hostile or giant report can't bloat the data branch (ids longer than this are cut).
17
+ MAX_ID, MAX_ROWS, MAX_ALL = 300, 500, 50_000
18
+ FAIL_TAGS = {"failure", "error"}
19
+ FLAKY_TAGS = {"flakyFailure", "flakyError"}
20
+ REPORT_FILES = (".xml", ".trx", ".json", ".jsonl", ".ndjson")
21
+ # A random test order's seed, so a flaky failure can be replayed: Surefire's runOrder seed, `go test -shuffle`.
22
+ SEED_PROPERTY = re.compile(r"seed|randomly", re.I)
23
+ GO_SEED = re.compile(r"-test\.shuffle (\d+)")
24
+ GO_LINE = re.compile(r"^\s*([\w.\-]+\.go:\d+: .*)$")
25
+ # Hosted runners' checkout folders (Linux and macOS, Windows, containers), so a test file reads as a repo path.
26
+ RUNNER_ROOT = re.compile(r"^(?:.*?[\\/]runner[\\/]work[\\/][^\\/]+[\\/][^\\/]+|[A-Za-z]:[\\/]a[\\/][^\\/]+[\\/][^\\/]+"
27
+ r"|/github/workspace)[\\/]")
28
+ TRX_FAILED = {"failed", "error", "timeout", "aborted"}
29
+ TRX_SKIPPED = {"notexecuted", "inconclusive", "pending", "notrunnable", "disconnected"}
30
+ JEST_SKIPPED = {"pending", "skipped", "todo", "disabled"}
31
+
32
+
33
+ def _failure(tid: str, type_attr: str | None, message: str | None, text: str | None, ms: int | None) -> list:
34
+ """[test id, per-test key, short message, cause, where, type, ms]: older records hold only the first three."""
35
+ f = analyse(type_attr, message, text)
36
+ shown = message or (f"{f.type}: {f.message}" if f.type else f.message) or text
37
+ return [tid, f.key(tid), _short(shown), f.cause, f.where, f.type, ms]
38
+
39
+
40
+ def _short(message: str | None) -> str:
41
+ text = (message or "").strip()
42
+ return text.splitlines()[0][:200] if text else ""
43
+
44
+
45
+ def _cap_id(tid: str) -> str:
46
+ # A long id is cut, with a short hash of the whole so two long ids never become one test.
47
+ return tid if len(tid) <= MAX_ID else f"{tid[:MAX_ID - 9]}~{hashlib.sha1(tid.encode()).hexdigest()[:8]}"
48
+
49
+
50
+ def _millis(value, scale: float = 1) -> int:
51
+ # Report numbers are untrusted: anything not finite or beyond a few months of run time counts as 0.
52
+ try:
53
+ ms = float(value) * scale
54
+ except (TypeError, ValueError, OverflowError):
55
+ return 0
56
+ return round(ms) if math.isfinite(ms) and 0 <= ms < 1e10 else 0
57
+
58
+
59
+ def _text(value) -> str | None:
60
+ return value if isinstance(value, str) else None
61
+
62
+
63
+ def _repo_path(path) -> str | None:
64
+ return RUNNER_ROOT.sub("", path).replace("\\", "/") if isinstance(path, str) and path else None
65
+
66
+
67
+ def _empty() -> dict:
68
+ # "all" (every test and its time) is read for new-test detection and shards, and never stored per report.
69
+ return {"total": 0, "skipped": 0, "failed": [], "flaky": [], "slow": [], "sk": [], "all": []}
70
+
71
+
72
+ def _case() -> dict:
73
+ return {"fail": None, "passed": False, "flaky": False, "skipped": False, "ms": 0, "detail": None}
74
+
75
+
76
+ def _outcomes(seen: dict[str, dict]) -> dict:
77
+ """Each test's outcomes, gathered over every time the report lists it, as one report: a failure that also passed
78
+ (a retry inside the same job) is a flake."""
79
+ out = _empty()
80
+ for tid, info in seen.items():
81
+ out["total"] += 1
82
+ if info["fail"] is not None and info["passed"] or info["flaky"]:
83
+ # Failed, then passed on a retry inside the same job (Gradle test-retry, pytest-rerunfailures, Surefire).
84
+ out["flaky"].append(tid)
85
+ elif info["fail"] is not None:
86
+ out["failed"].append(_failure(tid, *info["detail"], info["ms"]))
87
+ elif info["skipped"] and not info["passed"]:
88
+ out["skipped"] += 1
89
+ out["slow"] = sorted(([tid, i["ms"]] for tid, i in seen.items()), key=lambda x: -x[1])[:SLOW_KEEP]
90
+ out["sk"] = [tid for tid, i in seen.items() if i["skipped"] and not i["passed"] and i["fail"] is None and not i["flaky"]][:SKIP_KEEP]
91
+ out["all"] = [[tid, i["ms"]] for tid, i in seen.items()]
92
+ return out
93
+
94
+
95
+ def _junit(root) -> dict:
96
+ # A test can appear several times when a retry plugin re-ran it, so outcomes are gathered per test first.
97
+ seen: dict[str, dict] = {}
98
+ for case in root.iter("testcase"):
99
+ name, cls, file = case.get("name") or "?", case.get("classname"), case.get("file")
100
+ info = seen.setdefault(_cap_id("::".join(p for p in (file, cls, name) if p)), _case())
101
+ tags = {child.tag: child for child in case}
102
+ failure = next((tags[t] for t in FAIL_TAGS if t in tags), None)
103
+ if failure is not None:
104
+ info["fail"] = info["fail"] or (failure.get("message") or failure.text or "")
105
+ # The body holds the stack the fingerprint needs; only its result is kept.
106
+ info["detail"] = info["detail"] or (failure.get("type"), failure.get("message"), failure.text)
107
+ elif FLAKY_TAGS & tags.keys():
108
+ info["flaky"] = True
109
+ elif "skipped" in tags:
110
+ info["skipped"] = True
111
+ else:
112
+ info["passed"] = True
113
+ info["ms"] = max(info["ms"], _millis(case.get("time") or 0, 1000))
114
+ out = _outcomes(seen)
115
+ seed = next((p.get("value") for p in root.iter("property") if SEED_PROPERTY.search(p.get("name") or "") and p.get("value")), None)
116
+ return {**out, "seed": seed[:40]} if seed else out
117
+
118
+
119
+ def _span(value) -> int:
120
+ # TRX durations read "00:00:01.5000000".
121
+ m = re.match(r"^(\d+):(\d+):(\d+(?:\.\d+)?)$", value or "")
122
+ return _millis(int(m[1]) * 3600 + int(m[2]) * 60 + float(m[3]), 1000) if m else 0
123
+
124
+
125
+ def _trx(root) -> dict:
126
+ """A .NET TRX file (dotnet test --logger trx): each result named by its class and test name, parameters included."""
127
+ ns = root.tag[:root.tag.index("}") + 1] if root.tag.startswith("{") else ""
128
+ classes = {}
129
+ for unit in root.iter(f"{ns}UnitTest"):
130
+ method = unit.find(f"{ns}TestMethod")
131
+ if method is not None:
132
+ classes[unit.get("id")] = method.get("className")
133
+ seen: dict[str, dict] = {}
134
+ for result in root.iter(f"{ns}UnitTestResult"):
135
+ name = result.get("testName") or "?"
136
+ info = seen.setdefault(_cap_id("::".join(p for p in (classes.get(result.get("testId")), name) if p)), _case())
137
+ outcome = (result.get("outcome") or "").lower()
138
+ if outcome in TRX_FAILED:
139
+ error = result.find(f"{ns}Output/{ns}ErrorInfo")
140
+ message = (error.findtext(f"{ns}Message") or "").strip() if error is not None else ""
141
+ trace = error.findtext(f"{ns}StackTrace") if error is not None else None
142
+ info["fail"] = info["fail"] or message
143
+ info["detail"] = info["detail"] or (None, message or None, trace or message)
144
+ elif outcome == "passed":
145
+ info["passed"] = True
146
+ elif outcome in TRX_SKIPPED:
147
+ info["skipped"] = True
148
+ info["ms"] = max(info["ms"], _span(result.get("duration")))
149
+ return _outcomes(seen)
150
+
151
+
152
+ def _xml(data: bytes) -> dict | None:
153
+ try:
154
+ root = fromstring(data, forbid_dtd=True)
155
+ except (DefusedXmlException, ParseError, ValueError):
156
+ return None
157
+ return _trx(root) if root.tag.rsplit("}", 1)[-1] == "TestRun" else _junit(root)
158
+
159
+
160
+ def _ctrf(doc: dict) -> dict | None:
161
+ try:
162
+ tests = doc["results"]["tests"]
163
+ except (KeyError, TypeError):
164
+ return None
165
+ out, slow = _empty(), []
166
+ for t in tests if isinstance(tests, list) else []:
167
+ if not isinstance(t, dict):
168
+ continue
169
+ tid = _cap_id("::".join(str(p) for p in (t.get("filePath") or t.get("suite"), t.get("name") or "?") if p))
170
+ out["total"] += 1
171
+ status = t.get("status")
172
+ retries = t.get("retries")
173
+ if status == "failed":
174
+ ms = _millis(t["duration"]) if isinstance(t.get("duration"), (int, float)) else None
175
+ message = _text(t.get("message"))
176
+ out["failed"].append(_failure(tid, None, message, _text(t.get("trace")) or message, ms))
177
+ elif status == "passed" and (t.get("flaky") is True or isinstance(retries, (int, float)) and retries > 0):
178
+ out["flaky"].append(tid)
179
+ elif status in ("skipped", "pending"):
180
+ out["skipped"] += 1
181
+ if len(out["sk"]) < SKIP_KEEP:
182
+ out["sk"].append(tid)
183
+ ms = _millis(t["duration"]) if isinstance(t.get("duration"), (int, float)) else 0
184
+ out["all"].append([tid, ms])
185
+ if isinstance(t.get("duration"), (int, float)):
186
+ slow.append([tid, ms])
187
+ out["slow"] = sorted(slow, key=lambda x: -x[1])[:SLOW_KEEP]
188
+ return out
189
+
190
+
191
+ def _jest(doc: dict) -> dict:
192
+ """Jest's --json and Vitest's json reporter: each test named by its file and full name; a test that passed after
193
+ retries (jest.retryTimes, Vitest retry) is a flake."""
194
+ out, slow = _empty(), []
195
+ for suite in doc.get("testResults") or []:
196
+ if not isinstance(suite, dict):
197
+ continue
198
+ file = _repo_path(suite.get("name") or suite.get("testFilePath"))
199
+ for t in suite.get("assertionResults") or []:
200
+ if not isinstance(t, dict):
201
+ continue
202
+ name = t.get("fullName") or " ".join([*(str(a) for a in t.get("ancestorTitles") or []), str(t.get("title") or "?")])
203
+ tid = _cap_id("::".join(p for p in (file, str(name)) if p))
204
+ out["total"] += 1
205
+ status = t.get("status")
206
+ ms = _millis(t["duration"]) if isinstance(t.get("duration"), (int, float)) else 0
207
+ retried = (isinstance(t.get("invocations"), int) and t["invocations"] > 1) or bool(t.get("retryReasons"))
208
+ if status == "failed":
209
+ text = next((m for m in t.get("failureMessages") or [] if isinstance(m, str)), None)
210
+ out["failed"].append(_failure(tid, None, text, text, ms))
211
+ elif status == "passed" and retried:
212
+ out["flaky"].append(tid)
213
+ elif status in JEST_SKIPPED:
214
+ out["skipped"] += 1
215
+ if len(out["sk"]) < SKIP_KEEP:
216
+ out["sk"].append(tid)
217
+ out["all"].append([tid, ms])
218
+ if isinstance(t.get("duration"), (int, float)):
219
+ slow.append([tid, ms])
220
+ out["slow"] = sorted(slow, key=lambda x: -x[1])[:SLOW_KEEP]
221
+ return out
222
+
223
+
224
+ def _go(data: bytes) -> dict | None:
225
+ """`go test -json` (and gotestsum): one test per package and full name, subtests apart from their parents (a parent
226
+ fails whenever a subtest does); a test that failed and then passed in the same stream (a rerun) is a flake."""
227
+ seen: dict[str, dict] = {}
228
+ parents, seed, events = set(), None, 0
229
+ for line in data.splitlines():
230
+ try:
231
+ e = json.loads(line)
232
+ except ValueError:
233
+ continue # build output can sit between the events
234
+ if not isinstance(e, dict) or not isinstance(e.get("Action"), str):
235
+ continue
236
+ events += 1
237
+ output, name, pkg = _text(e.get("Output")), _text(e.get("Test")), _text(e.get("Package")) or ""
238
+ if output and seed is None and (m := GO_SEED.search(output)):
239
+ seed = m[1]
240
+ if not name:
241
+ continue
242
+ parts = name.split("/")
243
+ parents.update(f"{pkg}::{'/'.join(parts[:n])}" for n in range(1, len(parts)))
244
+ info = seen.setdefault(_cap_id(f"{pkg}::{name}" if pkg else name), {**_case(), "lines": []})
245
+ action = e["Action"]
246
+ if action == "output" and output:
247
+ info["lines"] = (info["lines"] + [output.rstrip("\n")])[-50:]
248
+ elif action == "fail":
249
+ found = next((m[1] for x in info["lines"] if (m := GO_LINE.match(x))), None)
250
+ info["fail"] = info["fail"] or found or ""
251
+ info["detail"] = info["detail"] or (None, found, "\n".join(info["lines"]))
252
+ elif action == "pass":
253
+ info["passed"] = True
254
+ elif action == "skip":
255
+ info["skipped"] = True
256
+ if action in ("pass", "fail", "skip"):
257
+ info["ms"] = max(info["ms"], _millis(e.get("Elapsed") or 0, 1000))
258
+ if not events:
259
+ return None
260
+ # A parent fails whenever a subtest does, so it is left out then; failing with every subtest passing, it is its own.
261
+ failed_below = {p for p in parents if any(t.startswith(p + "/") and i["fail"] is not None for t, i in seen.items())}
262
+ out = _outcomes({tid: i for tid, i in seen.items()
263
+ if tid not in parents or (i["fail"] is not None and tid not in failed_below)})
264
+ return {**out, "seed": seed} if seed else out
265
+
266
+
267
+ def _json(data: bytes) -> dict | None:
268
+ try:
269
+ doc = json.loads(data)
270
+ except ValueError:
271
+ return _go(data) # an event stream, one JSON object per line
272
+ if not isinstance(doc, dict):
273
+ return None
274
+ if "results" in doc:
275
+ return _ctrf(doc)
276
+ if isinstance(doc.get("testResults"), list):
277
+ return _jest(doc)
278
+ return _go(data) if isinstance(doc.get("Action"), str) else None
279
+
280
+
281
+ def parse_report(name: str, data: bytes) -> dict | None:
282
+ """One JUnit XML, TRX, CTRF JSON, Jest or Vitest JSON, or `go test -json` file; None when it is none of them or is
283
+ unsafe to read."""
284
+ head = data.lstrip()[:1]
285
+ low = name.lower()
286
+ if low.endswith((".xml", ".trx")) or head == b"<":
287
+ return _xml(data)
288
+ if low.endswith((".json", ".jsonl", ".ndjson")) or head == b"{":
289
+ return _json(data)
290
+ return None
291
+
292
+
293
+ def merge(parts: list[dict]) -> dict:
294
+ out = _empty()
295
+ for p in parts:
296
+ out["total"] += p["total"]
297
+ out["skipped"] += p["skipped"]
298
+ out["failed"] += p["failed"]
299
+ out["flaky"] += p["flaky"]
300
+ out["slow"] += p["slow"]
301
+ out["sk"] += p.get("sk") or []
302
+ out["all"] += p.get("all") or []
303
+ out["slow"] = sorted(out["slow"], key=lambda x: -x[1])[:SLOW_KEEP]
304
+ out["sk"] = out["sk"][:SKIP_KEEP]
305
+ out["failed"], out["flaky"], out["all"] = out["failed"][:MAX_ROWS], out["flaky"][:MAX_ROWS], out["all"][:MAX_ALL]
306
+ seed = next((p["seed"] for p in parts if p.get("seed")), None)
307
+ return {**out, "seed": seed} if seed else out
308
+
309
+
310
+ def parse_archive(data: bytes, max_file_bytes: int = 20_000_000, max_total_bytes: int = 200_000_000,
311
+ max_files: int = 500) -> dict:
312
+ """Every report inside an Actions artifact zip, read in memory with size limits (nothing is extracted)."""
313
+ parts, skipped, total, cov = [], 0, 0, None
314
+ with zipfile.ZipFile(io.BytesIO(data)) as z:
315
+ for info in z.infolist()[:max_files]:
316
+ if info.is_dir() or not info.filename.lower().endswith(REPORT_FILES + (".info", ".lcov")):
317
+ continue
318
+ # Sizes are checked before reading, so a zip bomb is skipped, not unpacked.
319
+ if info.file_size > max_file_bytes or total + info.file_size > max_total_bytes:
320
+ skipped += 1
321
+ continue
322
+ total += info.file_size
323
+ body = z.read(info)
324
+ found = coverage.parse(info.filename, body) if info.filename.lower().endswith((".xml", ".info", ".lcov")) else None
325
+ if found:
326
+ cov = _merge_cov(cov, found)
327
+ continue
328
+ if info.filename.lower().endswith((".info", ".lcov")):
329
+ continue
330
+ parsed = parse_report(info.filename, body)
331
+ if parsed is None:
332
+ if info.filename.lower().endswith((".xml", ".trx")):
333
+ skipped += 1
334
+ continue
335
+ parts.append(parsed)
336
+ # "cov" (coverage with its line hits) is read for patch coverage and never stored per report.
337
+ return {**merge(parts), "skipped_files": skipped, **({"cov": cov} if cov else {})}
338
+
339
+
340
+ def _merge_cov(a: dict | None, b: dict) -> dict:
341
+ """Two coverage files of one artifact as one: a line ran if it ran in either."""
342
+ if a is None:
343
+ return b
344
+ files = a["files"] # merged in place: copying everything again for each further file cost minutes on big reports
345
+ for path, hits in b["files"].items():
346
+ mine = files.setdefault(path, {})
347
+ for n, h in hits.items():
348
+ mine[n] = max(mine.get(n, 0), h)
349
+ lines = sum(len(h) for h in files.values())
350
+ covered = sum(1 for h in files.values() for x in h.values() if x > 0)
351
+ return {"format": a["format"] if a["format"] == b["format"] else "mixed", "lines": lines, "covered": covered, "files": files}