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,365 @@
1
+ """Insights that need two modules' history at once. The first: how long ready pull requests sat with failing checks on
2
+ their newest commit before they merged, each wait traced to the run that turned the PR red and the one that cleared it."""
3
+ from collections import defaultdict
4
+ from datetime import datetime, timedelta
5
+
6
+ from stackvitals.ci.analyze import _job_reads_pr
7
+ from stackvitals.core import metrics
8
+ from stackvitals.core.actors import kind_of
9
+ from stackvitals.core.joins import coverage_start, runs_by_pr
10
+ from stackvitals.core.stats import median
11
+ from stackvitals.core.time import iso, secs
12
+
13
+ FAIL = {"failure", "timed_out", "startup_failure"}
14
+ # No verdict on the code: stopped by a newer push, waiting for a maintainer's approval, or never ran.
15
+ NO_VERDICT = {"cancelled", "action_required", "stale", "skipped", None, ""}
16
+ DEFINITION = ("Time a ready pull request spent with a failing check on its newest commit: from the first failing job "
17
+ "until a new commit, a passing re-run on the same code, or the merge. GitHub Actions runs of the PR's own code only "
18
+ "(pull_request_target runs use the base branch's workflow and are left out, as are checks that read the PR's "
19
+ "title, description or labels); time spent as a draft never counts.")
20
+ TOP = 5
21
+
22
+
23
+ def _blamed(failed: list[dict]) -> list[dict]:
24
+ """The failed jobs that did the failing: a job that started only after every other failed job ended is a summary job
25
+ (it waits on them and fails when any did), so it is left out when others failed too."""
26
+ if len(failed) < 2:
27
+ return failed
28
+ real = [j for j in failed if not (j.get("st") and all(o.get("en") and j["st"] >= o["en"] for o in failed if o is not j))]
29
+ return real or failed
30
+
31
+
32
+ def _attempts(runs: list[dict], jobs: list[dict], meta: dict) -> list[dict]:
33
+ """Each finished run attempt with a verdict: when it ended and which of its jobs failed."""
34
+ by_attempt = defaultdict(list)
35
+ for j in jobs:
36
+ by_attempt[(j["run"], j.get("att", 1))].append(j)
37
+ out = []
38
+ for r in runs:
39
+ mine = by_attempt.get((r["id"], r["att"]), [])
40
+ reads_pr = meta.get(r.get("path") or "", ())
41
+ judged = [j for j in mine if not _job_reads_pr(j.get("n") or "", reads_pr)]
42
+ if mine and not judged:
43
+ continue # only checks of the PR's own text ran
44
+ if judged:
45
+ bad = [j for j in judged if j.get("c") in FAIL]
46
+ failed = sorted({j.get("n") or "?" for j in _blamed(bad)})
47
+ no_verdict = {j.get("c") for j in judged} <= NO_VERDICT
48
+ done = max((j["en"] for j in judged if j.get("en")), default=r.get("up"))
49
+ # GitHub shows the PR red as soon as one job fails, long before the whole run ends.
50
+ done = min((j["en"] for j in bad if j.get("en")), default=done) if bad else done
51
+ else:
52
+ failed = ["(run)"] if r.get("c") in FAIL else []
53
+ no_verdict = False
54
+ done = r.get("up")
55
+ if not done or (not failed and (no_verdict or r.get("c") in NO_VERDICT)):
56
+ continue
57
+ out.append({"run": r["id"], "attempt": r["att"], "sha": r.get("sha"), "wf": r.get("wf") or r.get("path"),
58
+ "workflow": r.get("wfn") or r.get("path"), "done": done, "failed": failed})
59
+ return out
60
+
61
+
62
+ def draft_spans(pr: dict) -> list[tuple[str, str]]:
63
+ """When the PR was a draft, from its ready/draft toggles: it starts as a draft when its first toggle is "ready"
64
+ (or it has none and is a draft now)."""
65
+ toggles = sorted((e for e in pr.get("ev") or [] if e[0] in ("ready", "draft") and len(e) > 1 and e[1]), key=lambda e: e[1])
66
+ draft = toggles[0][0] == "ready" if toggles else bool(pr.get("draft"))
67
+ spans, since = [], pr["cr"] if draft else None
68
+ for kind, when, *_ in toggles:
69
+ if kind == "draft" and since is None:
70
+ since = when
71
+ elif kind == "ready" and since is not None:
72
+ spans.append((since, when))
73
+ since = None
74
+ if since is not None:
75
+ spans.append((since, "9999"))
76
+ return spans
77
+
78
+
79
+ def _ready_parts(start: str, end: str, drafts: list[tuple[str, str]]) -> list[tuple[str, str]]:
80
+ # The parts of [start, end] when the PR was not a draft.
81
+ parts, cur = [], start
82
+ for a, b in drafts:
83
+ if b <= cur or a >= end:
84
+ continue
85
+ if a > cur:
86
+ parts.append((cur, a))
87
+ cur = max(cur, b)
88
+ if cur < end:
89
+ parts.append((cur, end))
90
+ return parts
91
+
92
+
93
+ def red_intervals(pr: dict, runs: list[dict], jobs: list[dict], meta: dict, end: str) -> list[dict]:
94
+ """The waits of one PR with failing checks on its newest commit, each with the run that started it and what ended it."""
95
+ first_run = {}
96
+ for r in runs:
97
+ if r.get("sha") and r.get("cr"):
98
+ seen = first_run.get(r["sha"])
99
+ if seen is None or (r["cr"], r["id"]) < seen:
100
+ first_run[r["sha"]] = (r["cr"], r["id"])
101
+ # A commit becomes the PR's newest when its first run is created (the push), not when it was authored.
102
+ events = [(cr, 0, ("head", sha, rid)) for sha, (cr, rid) in first_run.items()]
103
+ events += [(a["done"], 1, ("done", a)) for a in _attempts(runs, jobs, meta)]
104
+ events.sort(key=lambda e: (e[0], e[1]))
105
+ stop = pr.get("mg") or pr.get("cl") or end
106
+ latest, head, waits, current = {}, None, [], None
107
+ for when, _, event in events:
108
+ if when > stop:
109
+ break
110
+ if event[0] == "head":
111
+ head = event[1]
112
+ else:
113
+ a = event[1]
114
+ latest[(a["sha"], a["wf"])] = a
115
+ failing = [a for (sha, _wf), a in latest.items() if sha == head and a["failed"]]
116
+ if failing and current is None:
117
+ cause = event[1] if event[0] == "done" and event[1]["failed"] else min(failing, key=lambda a: a["done"])
118
+ current = {"from": when, "failed": {"run": cause["run"], "attempt": cause["attempt"], "workflow": cause["workflow"],
119
+ "jobs": cause["failed"]}}
120
+ elif not failing and current is not None:
121
+ if event[0] == "head":
122
+ current.update(to=when, ended_by="push", cleared={"sha": event[1], "run": event[2]})
123
+ else:
124
+ current.update(to=when, ended_by="rerun", cleared={"run": event[1]["run"], "attempt": event[1]["attempt"]})
125
+ waits.append(current)
126
+ current = None
127
+ if current is not None:
128
+ current.update(to=stop, ended_by="merged" if pr.get("mg") else "closed" if pr.get("cl") else "open", cleared=None)
129
+ waits.append(current)
130
+ out, drafts = [], draft_spans(pr)
131
+ for w in waits:
132
+ # A draft's failing checks are its author's work in progress: only the time it was ready counts.
133
+ parts = _ready_parts(w["from"], w["to"], drafts)
134
+ hours = sum(secs(a, b) for a, b in parts) / 3600
135
+ if parts and hours > 0:
136
+ out.append({**w, "from": parts[0][0], "hours": round(hours, 2)})
137
+ return out
138
+
139
+
140
+ def red_ci(prs: list[dict], ci: dict, now: datetime, window_days: int = 90, since: str | None = None, meta: dict | None = None,
141
+ state: dict | None = None) -> dict:
142
+ """How long people's merged PRs sat with failing checks, over the part of the CI history with no gaps."""
143
+ end = iso(now)
144
+ window = iso(now - timedelta(days=window_days))
145
+ start = since or coverage_start(state or {}, ci.get("runs") or [])
146
+ start = max(start, window) if start else window
147
+ if meta is None:
148
+ meta = {path: set(v.get("jobs") or []) for path, v in ((state or {}).get("triggers") or {}).items()}
149
+ by_pr = runs_by_pr(ci.get("runs") or [], prs)
150
+ jobs_by_run = defaultdict(list)
151
+ for j in ci.get("jobs") or []:
152
+ jobs_by_run[j["run"]].append(j)
153
+ rows, no_checks, open_red = [], 0, []
154
+ for p in prs:
155
+ if kind_of(p.get("au"), p.get("at")) != "person" or p["cr"] < start:
156
+ continue
157
+ runs = by_pr.get(p["n"], [])
158
+ mine = [j for rid in {r["id"] for r in runs} for j in jobs_by_run.get(rid, [])]
159
+ if p.get("mg"):
160
+ if p["mg"] > end:
161
+ continue
162
+ if not runs:
163
+ no_checks += 1
164
+ continue
165
+ waits = red_intervals(p, runs, mine, meta, end)
166
+ rows.append({"pr": p["n"], "red_h": round(sum(w["hours"] for w in waits), 2),
167
+ "rerun_h": round(sum(w["hours"] for w in waits if w["ended_by"] == "rerun"), 2),
168
+ "cycle_h": (secs(p.get("cr"), p["mg"]) or 0) / 3600, "waits": waits})
169
+ elif not p.get("cl") and runs:
170
+ waits = red_intervals(p, runs, mine, meta, end)
171
+ if waits and waits[-1]["ended_by"] == "open":
172
+ open_red.append({"pr": p["n"], "since": waits[-1]["from"], "hours": waits[-1]["hours"]})
173
+ red = [r for r in rows if r["red_h"] > 0]
174
+ jobs = defaultdict(lambda: {"hours": 0.0, "prs": set(), "runs": []})
175
+ tests = defaultdict(lambda: {"hours": 0.0, "prs": set(), "runs": []})
176
+ failed_tests = defaultdict(set)
177
+ for rep in ci.get("tests") or []:
178
+ failed_tests[(rep["run"], rep["att"])] |= {f[0] for f in rep["failed"]}
179
+ for r in rows:
180
+ for w in r["waits"]:
181
+ if w["ended_by"] == "rerun":
182
+ # A re-run that passed on the same code: the hours are shared by the jobs that had failed.
183
+ for name in w["failed"]["jobs"]:
184
+ entry = jobs[(w["failed"]["workflow"], name)]
185
+ entry["hours"] += w["hours"] / len(w["failed"]["jobs"])
186
+ entry["prs"].add(r["pr"])
187
+ entry["runs"].append([w["failed"]["run"], w["failed"]["attempt"]])
188
+ # And by the tests those jobs reported failing, when the run kept its test reports.
189
+ names = failed_tests.get((w["failed"]["run"], w["failed"]["attempt"])) or set()
190
+ for name in names:
191
+ entry = tests[name]
192
+ entry["hours"] += w["hours"] / len(names)
193
+ entry["prs"].add(r["pr"])
194
+ entry["runs"].append([w["failed"]["run"], w["failed"]["attempt"]])
195
+ measures = [metrics.measure("insight.red_ci_share", hits=len(red), n=len(rows), as_of=now, window_days=window_days),
196
+ metrics.measure("insight.red_ci_hours", values=[r["red_h"] for r in red], as_of=now, window_days=window_days)]
197
+ return {"definition": DEFINITION, "since": start, "until": end, "prs": len(rows), "without_checks": no_checks,
198
+ "red_prs": len(red), "red_hours_total": round(sum(r["red_h"] for r in rows), 2),
199
+ "rerun_hours_total": round(sum(r["rerun_h"] for r in rows), 2),
200
+ "rerun_waits": sum(1 for r in rows for w in r["waits"] if w["ended_by"] == "rerun"),
201
+ "measures": {m.metric: m.to_dict() for m in measures},
202
+ "cycle_hours": {"red": {"median": _round(median([r["cycle_h"] for r in red])), "n": len(red)},
203
+ "clear": {"median": _round(median([r["cycle_h"] for r in rows if not r["red_h"]])),
204
+ "n": len(rows) - len(red)}},
205
+ "rerun_jobs": sorted(({"workflow": wf, "job": job, "hours": round(v["hours"], 2), "prs": len(v["prs"]),
206
+ "runs": v["runs"][-5:]}
207
+ for (wf, job), v in jobs.items()), key=lambda x: -x["hours"]),
208
+ "rerun_tests": sorted(({"test": name, "hours": round(v["hours"], 2), "prs": len(v["prs"]),
209
+ "pr_list": sorted(v["prs"])[-10:], "runs": v["runs"][-5:]}
210
+ for name, v in tests.items()), key=lambda x: (-x["hours"], x["test"]))[:20],
211
+ "top": [{"pr": r["pr"], "hours": r["red_h"], "waits": r["waits"]}
212
+ for r in sorted(red, key=lambda r: -r["red_h"])[:TOP]],
213
+ "open_red": sorted(open_red, key=lambda x: x["since"])}
214
+
215
+
216
+ def evidence_chains(red: dict | None, ci: dict | None, decisions: dict, top: int = 10) -> list[dict]:
217
+ """Each test whose failures held merged PRs red until a re-run passed, traced end to end: the hours and PRs it held,
218
+ CI Health's verdict and flake count, the owners CODEOWNERS names for its file, and the decision recorded on it."""
219
+ tests = {t["id"]: t for t in (ci or {}).get("tests") or []}
220
+ out = []
221
+ for r in ((red or {}).get("rerun_tests") or [])[:top]:
222
+ t = tests.get(r["test"]) or {}
223
+ d = decisions.get(f"test:{r['test']}")
224
+ out.append({"test": r["test"], "hours": r["hours"], "prs": r["prs"], "pr_list": r.get("pr_list") or [],
225
+ "state": t.get("state"), "flake_events": t.get("flake_events"), "runs": t.get("runs"),
226
+ "owners": t.get("owners") or [], "owners_from": t.get("owners_from"),
227
+ "decision": {k: d.get(k) for k in ("verb", "by", "at", "until", "reason")} if d else None})
228
+ return out
229
+
230
+
231
+ def _round(x):
232
+ return None if x is None else round(x, 1)
233
+
234
+
235
+ ENDED = {"push": "a new commit", "rerun": "a passing re-run on the same code", "merged": "the merge (still red)",
236
+ "closed": "closing", "open": "nothing yet"}
237
+
238
+
239
+ def _plain(text, markdown: bool) -> str:
240
+ # Job and workflow names come from workflow files a fork's PR can change: no links, mentions or markup in summaries.
241
+ if not markdown:
242
+ return str(text)
243
+ from stackvitals.flow.report import _md
244
+ return _md(text).replace("@", "@​").replace("](", "]​(").replace("www.", "www​.")
245
+
246
+
247
+ def _span(hours: float) -> str:
248
+ return f"{hours / 24:.1f} days" if hours >= 48 else f"{hours:g} h"
249
+
250
+
251
+ def render(out: dict, repo: str, markdown: bool = False) -> str:
252
+ """The red-CI insight in a few lines: counts, ranges, the longest waits with links to the runs, open PRs red now."""
253
+ share, hours = out["measures"]["insight.red_ci_share"], out["measures"]["insight.red_ci_hours"]
254
+ head = (f"Red CI in {repo}: people's merged pull requests opened since {out['since'][:10]} that ran checks: "
255
+ f"{out['prs']}" + (f" ({out['without_checks']} more ran none)." if out.get("without_checks") else "."))
256
+ if not out["prs"]:
257
+ return head + " Nothing to measure yet."
258
+ pct = lambda x: f"{x * 100:.0f}%" # noqa: E731
259
+ line = f"- {out['red_prs']} of {out['prs']} spent time ready for review with a failing check on their newest commit"
260
+ if share["value"] is not None:
261
+ line += f" ({pct(share['value'])}, range {share['low'] * 100:.0f}-{pct(share['high'])})"
262
+ lines = [head, line + "."]
263
+ if out["red_prs"]:
264
+ line = f"- {out['red_hours_total']:g} h in all"
265
+ if hours["value"] is not None:
266
+ span = f" (range {hours['low']:.1f}-{hours['high']:.1f} h)" if hours["low"] is not None else ""
267
+ line += f"; a median {hours['value']:.1f} h per PR{span}"
268
+ lines.append(line + ".")
269
+ if out["rerun_waits"]:
270
+ jobs = "; ".join(f"{_plain(j['job'], markdown)} ({_plain(j['workflow'], markdown)}) {j['hours']:g} h on {j['prs']} "
271
+ f"PR{'' if j['prs'] == 1 else 's'}" for j in out["rerun_jobs"][:5])
272
+ lines.append(f"- {out['rerun_waits']} of those waits ended with a re-run that passed on the same code: "
273
+ f"{out['rerun_hours_total']:g} h. {jobs}.")
274
+ if out.get("rerun_tests"):
275
+ named = "; ".join(f"{_plain(t['test'], markdown)} {t['hours']:g} h on {t['prs']} PR{'' if t['prs'] == 1 else 's'}"
276
+ for t in out["rerun_tests"][:5])
277
+ lines.append(f"- Flaky tests behind those re-runs: {named}.")
278
+ cycle = out["cycle_hours"]
279
+ if cycle["red"]["median"] is not None and cycle["clear"]["median"] is not None:
280
+ lines.append(f"- PRs that went red took a median {cycle['red']['median']:g} h from opening to merge "
281
+ f"(n={cycle['red']['n']}), the others {cycle['clear']['median']:g} h (n={cycle['clear']['n']}): "
282
+ f"an association, not a cause.")
283
+ for t in out["top"]:
284
+ w = max(t["waits"], key=lambda x: x["hours"])
285
+ f = w["failed"]
286
+ jobs = ", ".join(_plain(j, markdown) for j in f["jobs"][:3]) + (", ..." if len(f["jobs"]) > 3 else "")
287
+ lines.append(f"- #{t['pr']}: {t['hours']:g} h red; the longest wait, {w['hours']:g} h, began when "
288
+ f"{_plain(f['workflow'], markdown)} failed ({jobs}) in https://github.com/{repo}/actions/runs/{f['run']}/"
289
+ f"attempts/{f['attempt']} and ended with {ENDED[w['ended_by']]}.")
290
+ if out["open_red"]:
291
+ lines.append("Open PRs red right now: " + ", ".join(f"#{o['pr']} for {_span(o['hours'])}" for o in out["open_red"][:10]) + ".")
292
+ lines.append(f"Definition: {out['definition']}")
293
+ return "\n".join(lines)
294
+
295
+
296
+ def insights_step(ws, env: dict, now: datetime) -> dict | None:
297
+ """The Action's insights from what the job's checkout holds: what to fix first across modules (Team Flow is enough),
298
+ and the red-CI insight when CI Health's runs are there too."""
299
+ import html
300
+ import json
301
+
302
+ from stackvitals import shell
303
+ from stackvitals.ci.store import DataStore
304
+ from stackvitals.core import findings
305
+ from stackvitals.core.store import accept, published, write_if_changed
306
+ from stackvitals.flow.store import FlowStore
307
+ if not (ws.path / "flow" / "closed").is_dir():
308
+ return None
309
+ from stackvitals.core.decisions import visible
310
+ flow = visible(ws.path, ws.cache.get("flow") or FlowStore(ws.path).load(), now.date())
311
+ prs = flow["open"] + flow["closed"]
312
+ out, bumps = None, []
313
+ if (ws.path / "ci" / "days").is_dir():
314
+ ci_store = DataStore(ws.path)
315
+ ci, state = ws.cache.get("ci") or ci_store.load(), ci_store.state()
316
+ out = red_ci(prs, ci, now, state=state)
317
+ meta = {path: set(v.get("jobs") or []) for path, v in (state.get("triggers") or {}).items()}
318
+ bumps = findings.bump_reds(prs, ci, now, meta, since=out["since"])
319
+ docs = ws.path / "docs"
320
+ docs.mkdir(parents=True, exist_ok=True)
321
+
322
+ def read(name):
323
+ try:
324
+ return accept(json.loads((docs / name).read_text(encoding="utf-8")))
325
+ except (OSError, ValueError):
326
+ return None
327
+ ledger = None
328
+ if bumps:
329
+ from stackvitals.deps.evidence import load_ledger
330
+ # How the same updates went across the crawl: only read when an update here sat red.
331
+ ledger = load_ledger(env.get("STACKVITALS_TOKEN"), ws.work / "deps")
332
+ found = findings.build(ws.repo, out, ws.cache.get("delivery") or read("delivery.json"), read("flow.json"), bumps, now, ledger,
333
+ began=findings.began_after(read("data.json"), prs))
334
+ # How long each finding has been around, and what people decided about it (acknowledged, snoozed).
335
+ from stackvitals.core.decisions import Store as Decisions
336
+ days_file = ws.path / "insights" / "ledger.json"
337
+ try:
338
+ before = json.loads(days_file.read_text(encoding="utf-8"))
339
+ except (OSError, ValueError):
340
+ before = {}
341
+ decided = Decisions(ws.path).state(now.date())
342
+ aside = {k.split(":", 1)[1] for k, d in decided.items() if k.startswith("finding:") and d.get("verb") in findings.SET_ASIDE}
343
+ days = findings.update_ledger(before, found, now, aside)
344
+ if days != before:
345
+ days_file.parent.mkdir(parents=True, exist_ok=True)
346
+ days_file.write_text(json.dumps(days, indent=1, sort_keys=True), encoding="utf-8")
347
+ found = findings.annotate(found, days, decided, now)
348
+ chains = evidence_chains(out, read("data.json"), decided)
349
+ red_html = ("<h2>Red checks on pull requests</h2>" + "".join(f"<p>{html.escape(line)}</p>" for line in render(out, ws.repo).splitlines())
350
+ if out and out["prs"] else "")
351
+ write_if_changed(docs / "insights.json", published({"generated": iso(now), "red_ci": out, "findings": found,
352
+ "chains": chains, "track_record": findings.track_record(days, now),
353
+ "opportunity": findings.opportunity(found)}),
354
+ also=lambda: (docs / "insights.html").write_text(findings.render_html(
355
+ found, ws.repo, now, shell.present(docs) | {"insights.html"}, red_html), encoding="utf-8"))
356
+
357
+ def after_commit():
358
+ summary = env.get("GITHUB_STEP_SUMMARY")
359
+ if summary and (found or (out and out["prs"])):
360
+ with open(summary, "a", encoding="utf-8") as f:
361
+ f.write(findings.render_markdown(found, ws.repo))
362
+ if out and out["prs"]:
363
+ f.write("### StackVitals insights\n" + render(out, ws.repo, markdown=True) + "\n")
364
+ ws.after_commit(after_commit, "Insights", first=True)
365
+ return {"prs": out["prs"] if out else 0, "red_prs": out["red_prs"] if out else 0, "findings": len(found)}
@@ -0,0 +1,43 @@
1
+ """`stackvitals insights owner/repo`: findings that need two modules' history at once, for any repo, from your machine."""
2
+ import argparse
3
+ import json
4
+ import sys
5
+
6
+ import httpx
7
+
8
+ from stackvitals.ci import cli as ci_cli
9
+ from stackvitals.ci.api import APIError, OutOfBudget
10
+ from stackvitals.core import services
11
+ from stackvitals.core.insights import render
12
+
13
+
14
+ def main(argv: list[str] | None = None, tok: str | None = None) -> int:
15
+ parser = argparse.ArgumentParser(prog="stackvitals insights",
16
+ description="What to fix first across modules, and how long merged pull requests sat "
17
+ "with failing checks, traced to the runs.")
18
+ parser.add_argument("repo", help="owner/name")
19
+ parser.add_argument("--days", type=int, default=30, help="CI history to read when the repo doesn't run StackVitals (max 90)")
20
+ parser.add_argument("--json", action="store_true")
21
+ args = parser.parse_args(argv)
22
+ tok = tok or ci_cli.token()
23
+ if not tok:
24
+ print("A GitHub token is needed. Set GITHUB_TOKEN, or run `gh auth login`.", file=sys.stderr)
25
+ return 1
26
+ days = max(1, min(args.days, 90))
27
+ log = lambda line: print(line, file=sys.stderr, flush=True) # noqa: E731
28
+ try:
29
+ found, _ = services.findings(args.repo, days, tok, ci_cli.home(), log=log)
30
+ out, source = services.red_ci(args.repo, days, tok, ci_cli.home(), log=log)
31
+ except (APIError, OutOfBudget, httpx.HTTPError) as exc:
32
+ print(f"Could not read {args.repo}: {exc}", file=sys.stderr)
33
+ return 1
34
+ if args.json:
35
+ print(json.dumps({"repo": args.repo, "source": source, "findings": found, "red_ci": out}, indent=1))
36
+ return 0
37
+ print("What to fix first:" if found else "What to fix first: nothing stands out yet.")
38
+ for k, f in enumerate(found[:10], 1):
39
+ links = ", ".join(e["url"] for e in f["evidence"][:3] if e["url"].startswith("https://"))
40
+ print(f" {k}. {f['title']} {f['action'] or ''} (n={f['n']}; {f['hours']} h at stake){' ' + links if links else ''}")
41
+ print()
42
+ print(render(out, args.repo) + f"\n(Source: {source}.)")
43
+ return 0
@@ -0,0 +1,69 @@
1
+ """docs/install.md: the workflow file itself, which module ids exist and what each needs, and every Action input, built
2
+ from the template `stackvitals ci init` prints and from action.yml, so the page can't drift from either."""
3
+ import yaml
4
+
5
+ from stackvitals.ci.cli import ACTION_REF, workflow_template
6
+ from stackvitals.portal.cli import WORKFLOW as PORTAL_WORKFLOW
7
+
8
+ # id, what it gives you, when it runs, what it needs besides itself
9
+ MODULES = [
10
+ ("ci", "CI Health: flaky and slowing jobs and tests, the PR verdict comment and check, test reports, owners",
11
+ "after each CI run finishes, and every 3 hours", "nothing; alone is fine"),
12
+ ("flow", "Team Flow, and with it the Delivery page (cycle time, DORA, AI-assisted work, investment) and Insights "
13
+ "(fix first, alerts, the weekly digest)",
14
+ "the schedule and manual runs", "nothing; Delivery's CI parts (failed deployments from CI, merge-queue flakes) and "
15
+ "Insights' red-CI findings also need `ci`"),
16
+ ("deps", "Dependency Steward: known vulnerabilities and malware in every lockfile, the check on PRs that change a lockfile, runtimes, and the weekly supply chain page",
17
+ "after each CI run (with `ci`) and every 3 hours for vulnerabilities; pull requests that change a lockfile; weekly for the supply chain", "nothing; alone is fine"),
18
+ ("community", "Community: newcomers, first answers, who carries the work, stars and traffic",
19
+ "the schedule and manual runs", "nothing; it reads the PRs itself when `flow` isn't on"),
20
+ ("portal", "Portal: the org's catalog, owners and scorecards",
21
+ "the schedule, from the org's portal repo with its own workflow (below)", "a portal repo; an org App for private repos"),
22
+ ]
23
+
24
+
25
+ def _inputs(action_yml: str) -> list[str]:
26
+ inputs = (yaml.safe_load(action_yml) or {}).get("inputs") or {}
27
+ rows = ["| Input | Default | What it does |", "|---|---|---|"]
28
+ for name, spec in inputs.items():
29
+ default = spec.get("default")
30
+ shown = f"`{default}`" if default not in (None, "") else ""
31
+ rows.append(f"| `{name}` | {shown} | {' '.join(str(spec.get('description') or '').split())} |")
32
+ return rows
33
+
34
+
35
+ def markdown(action_yml: str) -> str:
36
+ owner_repo = ACTION_REF.split("@", 1)[0]
37
+ lines = ["# Install", "",
38
+ f"StackVitals is a GitHub Action, `uses: {ACTION_REF}`, run by one workflow file in your repo. The file below "
39
+ "is exactly what `stackvitals ci init owner/repo` prints (that command also fills in your CI workflows' "
40
+ "names) and what the app's Setup page commits for you. Save it as `.github/workflows/stackvitals.yml`; "
41
+ "committing it starts the first run. No settings file is needed: a missing `.github/stackvitals.toml` means "
42
+ "the defaults ([settings.md](settings.md)).", "",
43
+ "## Modules", "",
44
+ "Pick modules with the `modules` input, comma separated. To run one alone, list only it, for example "
45
+ "`modules: ci`. Delivery and Insights have no id of their own: they come with `flow`.", "",
46
+ "| Id | What you get | Runs on | Needs |", "|---|---|---|---|"]
47
+ lines += [f"| `{i}` | {what} | {when} | {needs} |" for i, what, when, needs in MODULES]
48
+ lines += ["", "The default is `ci,flow,deps,community`. `stackvitals ci init --modules ci,deps` (or the app's Setup "
49
+ "picker) writes a file with only those modules' triggers and permissions, each lowered to read where nothing "
50
+ "of theirs writes; turning on a writing switch later (like `deps-comments`, which needs `pull-requests: write`) "
51
+ "means raising that permission too.", "",
52
+ "## The workflow file", "",
53
+ "Change `workflows: [\"CI\"]` to the `name:` lines of your CI workflows (the names, not the file names): "
54
+ "`workflow_run` matches names. The permissions are what every default switch needs; each comment says what "
55
+ "to lower to turn a part off. Team Flow's automation (review requests, size labels) and PR comments need "
56
+ "`pull-requests: write`; the weekly digest, commands and flaky-test issues need `issues: write`. A settings "
57
+ "change applied from the app opens a pull request, which needs the repo setting \"Allow GitHub Actions to "
58
+ "create and approve pull requests\" (Settings, Actions, General).", "",
59
+ "```yaml", workflow_template().rstrip(), "```", "",
60
+ "## The portal workflow", "",
61
+ "Portal runs in the org's own portal repo (private is best) with this file, which "
62
+ "`stackvitals portal init` prints; `stackvitals portal check ORG` says what your token can read first.", "",
63
+ "```yaml", PORTAL_WORKFLOW.format(ref=ACTION_REF).rstrip(), "```", "",
64
+ "## Every input", "",
65
+ f"From [action.yml](https://github.com/{owner_repo}/blob/main/action.yml). Inputs use hyphens "
66
+ "(`rerun-flaky`); the same switches in `.github/stackvitals.toml` use underscores (`rerun_flaky`), and the "
67
+ "file wins when both are set.", ""]
68
+ lines += _inputs(action_yml)
69
+ return "\n".join(lines) + "\n"