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,333 @@
1
+ """The weekly digest: what changed since last week in each module that ran, what to fix first, and which decisions reach
2
+ their review date soon, as one short message posted where people already look (a comment on one pinned issue, so
3
+ GitHub's own notifications deliver it, and the chat webhook). Nobody is pinged and item names can't break the Markdown."""
4
+ import html
5
+ import os
6
+ from datetime import date, datetime, timedelta
7
+
8
+ from stackvitals.core import changes
9
+
10
+ TOP = 3
11
+
12
+
13
+ def _code(text) -> str:
14
+ # An item name as inline code that nothing inside it can break out of.
15
+ return "`" + " ".join(str(text).replace("`", "'").split()) + "`"
16
+
17
+
18
+ def _quiet(text) -> str:
19
+ # Free text (titles, reasons) that can't ping anyone, become a link or start Markdown.
20
+ out = html.escape(" ".join(str(text).split()), quote=False)
21
+ for ch in "\\*_[]()#|":
22
+ out = out.replace(ch, "\\" + ch)
23
+ return out.replace("@", "@​").replace("www.", "www​.").replace("://", ":​//")
24
+
25
+
26
+ def _pct(x) -> str:
27
+ return f"{x:.0%}"
28
+
29
+
30
+ def _moved(c: dict) -> str:
31
+ # One change that survived the scan, in words: "pickup rose to 30.0 h from 3.0 h (medians, n 6 and 20)".
32
+ verb = "rose" if c["direction"] == "up" else "fell"
33
+ if c["unit"] == "share":
34
+ return f"{c['label']} {verb} to {_pct(c['now'])} from {_pct(c['base'])} (n {c['n_now']} and {c['n_base']})"
35
+ if c["unit"] == "count":
36
+ return f"{c['label']} {verb} to {c['now']} from {c['base']:g} a week"
37
+ return f"{c['label']} {verb} to {c['now']:.1f} {c['unit']} from {c['base']:.1f} {c['unit']} (medians, n {c['n_now']} and {c['n_base']})"
38
+
39
+
40
+ def _could_see(checked: list[dict]) -> str:
41
+ # When review times held steady, how big a change this week's PRs could have shown (so "steady" isn't oversold).
42
+ run = sum(1 for c in checked if c.get("note") is None)
43
+ times = [(changes.detectable(c, run), c) for c in checked if c["metric"] in ("delivery.pickup", "delivery.cycle_to_merge")
44
+ and c.get("note") is None]
45
+ times = [(f, c) for f, c in times if f]
46
+ if not times or any(c["changed"] for c in checked if c["metric"].startswith(("delivery.pickup", "delivery.cycle"))):
47
+ return ""
48
+ f, c = min(times, key=lambda x: x[0])
49
+ size = f"{f:.0f}" if f >= 3 else f"{f:.1f}"
50
+ return (f"; with {c['n_now']} PRs merged this week, review times would have had to change about {size}× to "
51
+ "stand out")
52
+
53
+
54
+ def _verdict(name: str, c: dict) -> str | None:
55
+ # One like-with-like comparison in words: "reverted more often (+8 points, likely +2 to +14)".
56
+ if not c or c.get("verdict") in (None, "too few"):
57
+ return None
58
+ if c["verdict"] == "no measurable difference":
59
+ return f"{name}: no measurable difference (it could detect {round(c['detectable'] * 100)} points or more)"
60
+ pts = lambda x: f"{x * 100:+.0f}" # noqa: E731
61
+ return f"{name} {'more' if c['verdict'] == 'higher' else 'less'} often ({pts(c['gap'])} points, likely {pts(c['low'])} to {pts(c['high'])})"
62
+
63
+
64
+ def _ai_line(matched: dict, coverage: dict) -> str | None:
65
+ """People's AI-marked PRs against the same authors' other PRs, and agents' PRs against people's of the same month and
66
+ size; only comparisons with enough matched PRs, each with its verdict."""
67
+ a = matched.get("assisted") or {}
68
+ said = [v for v in (_verdict("reverted", a.get("reverted")), _verdict("CI failures", a.get("ci_failed"))) if v]
69
+ agent = _verdict("merged by someone else", (matched.get("agent") or {}).get("merged_by_others"))
70
+ if not said and not agent:
71
+ return None
72
+ text = "**AI-assisted work:**"
73
+ if coverage.get("marked_share") is not None:
74
+ text += (f" {_pct(coverage['marked_share'])} of people's merged PRs carry an AI marker "
75
+ f"({coverage.get('authors_marking', 0)} of {coverage.get('authors', 0)} authors).")
76
+ if said:
77
+ text += f" Against the same authors' other PRs: {'; '.join(said)}."
78
+ if agent and "no measurable difference" in agent:
79
+ text += f" Coding agents' PRs against people's PRs of the same month and size, {agent}."
80
+ elif agent:
81
+ text += f" Coding agents' PRs: {agent} than people's PRs of the same month and size."
82
+ return text
83
+
84
+
85
+ def _flaky_keys(data: dict) -> list[str]:
86
+ keys = [f"test {t['id']}" for t in data.get("tests") or [] if t.get("state") == "flaky"]
87
+ keys += [f"job {(j.get('workflow') or '?').rsplit('/', 1)[-1]} / {j.get('job')}" for j in data.get("jobs") or []
88
+ if j.get("state") == "flaky"]
89
+ return sorted(keys)
90
+
91
+
92
+ def numbers(docs: dict) -> dict:
93
+ """The figures next week's digest compares with."""
94
+ data, delivery, flow = docs.get("data") or {}, docs.get("delivery") or {}, docs.get("flow") or {}
95
+ phases = delivery.get("phases") or {}
96
+ p75 = lambda name: (phases.get(name) or {}).get("p75_h")
97
+ return {"first_try": (data.get("pushes") or {}).get("first_try_share") if data else None,
98
+ "flaky": _flaky_keys(data) if data else [],
99
+ "pickup_p75_h": p75("pickup"), "review_p75_h": p75("review"), "cycle_p75_h": p75("cycle_to_merge"),
100
+ "deployments": (delivery.get("dora") or {}).get("deployments"), "open_prs": flow.get("open"),
101
+ "newcomers": (docs.get("community") or {}).get("waiting_total")}
102
+
103
+
104
+ def due(state: dict, today: date, days: int = 7) -> list[dict]:
105
+ """Decisions whose review date falls in the next `days` days, soonest first."""
106
+ end = (today + timedelta(days=days)).isoformat()
107
+ return sorted((v for v in state.values() if v.get("until") and today.isoformat() <= v["until"] <= end),
108
+ key=lambda v: v["until"])
109
+
110
+
111
+ def compose(repo: str, now: datetime, docs: dict, last: dict | None, decisions_due: list[dict],
112
+ app_url: str | None) -> tuple[str, dict]:
113
+ """(Markdown, numbers): this week's digest from the published JSON files, and what to compare against next week."""
114
+ nums, before = numbers(docs), (last or {}).get("numbers") or {}
115
+ week = (now.date() - timedelta(days=now.weekday())).isoformat()
116
+ lines = [f"### StackVitals weekly digest: {_quiet(repo)}, week of {week}", ""]
117
+ findings = (docs.get("insights") or {}).get("findings") or []
118
+ # Findings someone snoozed or acknowledged stay off the list, as on the pages.
119
+ aside = sum(1 for f in findings if f.get("decision"))
120
+ found = sorted((f for f in findings if not f.get("decision")), key=lambda f: -(f.get("hours") or 0))[:TOP]
121
+ if found:
122
+ lines.append("**What to fix first**")
123
+ lines += [f"{i}. {_quiet(f.get('title'))} ({f.get('hours') or 0:.1f} h at stake, n={f.get('n')})"
124
+ for i, f in enumerate(found, 1)]
125
+ if aside:
126
+ lines.append(f"({aside} set aside by a decision.)")
127
+ lines.append("")
128
+ checked = changes.flag([c for name in ("data", "delivery", "community") for c in (docs.get(name) or {}).get("changes") or []])
129
+ run = [c for c in checked if c.get("note") is None and not c.get("shown_with")]
130
+ if checked and not run:
131
+ lines.append("**This week:** too few PRs, pushes or deployments to compare with the four weeks before.")
132
+ elif run:
133
+ moved = [_moved(c) for c in run if c["changed"]]
134
+ held = len(run) - len(moved)
135
+ tail = (f"{len(run)} number{'' if len(run) == 1 else 's'} checked against the four weeks before; "
136
+ + (f"the other {held} held steady within noise" if moved else "all held steady within noise"))
137
+ tail += _could_see(run)
138
+ lines.append(f"**This week:** {'; '.join(moved)}. {tail}." if moved else f"**This week:** {tail}.")
139
+ if docs.get("data"):
140
+ part = f"first-try green {_pct(nums['first_try'])}" if nums["first_try"] is not None else "first-try green: not enough runs"
141
+ if last:
142
+ new = [k for k in nums["flaky"] if k not in set(before.get("flaky") or [])]
143
+ gone = [k for k in before.get("flaky") or [] if k not in set(nums["flaky"])]
144
+ if new:
145
+ part += "; newly flaky: " + ", ".join(_code(k.split(" ", 1)[1]) for k in new[:5]) + (
146
+ f" and {len(new) - 5} more" if len(new) > 5 else "")
147
+ if gone:
148
+ part += f"; {len(gone)} recovered"
149
+ else:
150
+ part += f"; {len(nums['flaky'])} flaky now"
151
+ lines.append(f"**CI:** {part}.")
152
+ if docs.get("delivery"):
153
+ parts = []
154
+ for key, label in (("pickup_p75_h", "pickup P75"), ("review_p75_h", "review P75"), ("cycle_p75_h", "cycle to merge P75")):
155
+ value = nums[key]
156
+ parts.append(f"{label} not enough data" if value is None else f"{label} {value:.1f} h")
157
+ dora = docs["delivery"].get("dora") or {}
158
+ shipped = dora.get("deployments") or 0
159
+ tail = (f"; {shipped} deployment{'' if shipped == 1 else 's'} in {docs['delivery'].get('window_days', 90)} days"
160
+ if docs["delivery"].get("deploy_source") else "; no deployment source found yet")
161
+ if dora.get("change_failure_rate") is not None:
162
+ tail += f"; change failure {_pct(dora['change_failure_rate'])}"
163
+ if dora.get("rework_rate") is not None:
164
+ tail += f"; rework {_pct(dora['rework_rate'])}"
165
+ lines.append(f"**Delivery:** {', '.join(parts)}{tail}.")
166
+ ai = (docs["delivery"].get("ai") or {}).get("outcomes") or {}
167
+ line = _ai_line(ai.get("matched") or {}, ai.get("coverage") or {})
168
+ if line:
169
+ lines.append(line)
170
+ agreed = docs["delivery"].get("agreements")
171
+ if agreed:
172
+ n = len(agreed.get("exceptions") or [])
173
+ each = "; ".join(f"{_quiet(label)}: {agreed['counts'].get(rule, 0)}" for rule, label in agreed["labels"].items())
174
+ lines.append(f"**Working agreements:** {n} exception{'' if n == 1 else 's'} in the last "
175
+ f"{agreed.get('window_days', 14)} days ({each}).")
176
+ if docs.get("flow"):
177
+ n_open, stale = docs["flow"].get("open") or 0, docs["flow"].get("stale") or 0
178
+ lines.append(f"**Pull requests:** {n_open} open pull request{'' if n_open == 1 else 's'}, {stale} stale.")
179
+ deps = docs.get("deps")
180
+ if deps:
181
+ parts = []
182
+ if deps.get("open"):
183
+ n, oldest = len(deps["open"]), max(u["age_hours"] for u in deps["open"]) / 24
184
+ parts.append(f"{n} update pull request{'' if n == 1 else 's'} open (the oldest {oldest:.0f} days)")
185
+ if deps.get("security_overdue"):
186
+ n = len(deps["security_overdue"])
187
+ parts.append(f"{n} security update{'' if n == 1 else 's'} waiting over 48 h")
188
+ if (deps.get("quiet_days") or 0) >= 30:
189
+ parts.append(f"no update pull request from {' or '.join(deps.get('bots') or ['the bot'])} for {deps['quiet_days']} "
190
+ "days (Dependabot pauses after 90 days without interaction)")
191
+ if parts:
192
+ lines.append(f"**Dependencies:** {'; '.join(parts)}.")
193
+ if docs.get("community"):
194
+ waiting = docs["community"].get("waiting_total") or 0
195
+ lines.append(f"**Community:** {waiting} newcomer{'' if waiting == 1 else 's'} waiting for a first answer."
196
+ if waiting else "**Community:** no newcomer waiting for a first answer.")
197
+ if decisions_due:
198
+ lines += ["", "**Decisions to review**"]
199
+ lines += [f"- {d['verb']} of {_code(d['id'])} ends {d['until']} (by {_code(d.get('by') or '?')}"
200
+ + (f": {_quiet(d['reason'])}" if d.get("reason") else "") + ")" for d in decisions_due]
201
+ if app_url:
202
+ lines += ["", f"[Open StackVitals]({app_url})"]
203
+ return "\n".join(lines).strip() + "\n", nums
204
+
205
+
206
+ TITLE = "StackVitals weekly digest"
207
+ LABEL = "stackvitals"
208
+ INTRO = ("StackVitals posts a short digest here every week: what changed in CI, delivery and pull requests, what to fix "
209
+ "first, and which decisions end soon. Subscribe to this issue to get it by e-mail or in GitHub. Change the day, "
210
+ "hour or channels under `[digest]` in `.github/stackvitals.toml`, or turn it off with `enabled = false`.")
211
+ FILES = (("data", "data.json"), ("flow", "flow.json"), ("delivery", "delivery.json"), ("insights", "insights.json"),
212
+ ("community", "community.json"), ("deps", "deps.json"))
213
+ EVENTS = ("schedule", "workflow_dispatch")
214
+ CATCH_UP = timedelta(hours=12)
215
+
216
+
217
+ def slot(now: datetime, day: int, hour: int) -> tuple[str, datetime] | None:
218
+ """(ISO week, time) of the digest due now: this week's slot once reached, or last week's when missed by under 12
219
+ hours (a Sunday-night slot falls after the week's last run); None when nothing is due."""
220
+ start = datetime(now.year, now.month, now.day, tzinfo=now.tzinfo) - timedelta(days=now.weekday())
221
+ at = start + timedelta(days=day, hours=hour)
222
+ if now < at:
223
+ at -= timedelta(days=7)
224
+ if now - at > CATCH_UP:
225
+ return None
226
+ year, week, _ = at.isocalendar()
227
+ return f"{year}-W{week:02d}", at
228
+
229
+
230
+ def _read(path):
231
+ import json
232
+ try:
233
+ return json.loads(path.read_text(encoding="utf-8"))
234
+ except (OSError, ValueError):
235
+ return None
236
+
237
+
238
+ def _issue(api, repo: str) -> int:
239
+ """The digest issue: the open one with its title and label, else a new one, pinned when GitHub allows it."""
240
+ found = [i for i in api._get(f"/repos/{repo}/issues", {"labels": LABEL, "state": "open", "per_page": 100}) or []
241
+ if i.get("title") == TITLE and "pull_request" not in i]
242
+ if found:
243
+ return found[0]["number"]
244
+ made = api.send("POST", f"/repos/{repo}/issues", {"title": TITLE, "body": INTRO, "labels": [LABEL]})
245
+ try:
246
+ from stackvitals.ci.api import GRAPHQL
247
+ api.client.post(GRAPHQL, headers=api.headers, json={
248
+ "query": "mutation($id: ID!) { pinIssue(input: {issueId: $id}) { issue { number } } }",
249
+ "variables": {"id": made.get("node_id")}})
250
+ except Exception: # noqa: BLE001 - an unpinned digest still works
251
+ pass
252
+ return made["number"]
253
+
254
+
255
+ def digest_step(ws, env: dict, now: datetime, make_api=None, client=None) -> dict | None:
256
+ """Once per ISO week, on the first scheduled run at or after the repo's day and hour: the digest from the pages this
257
+ job just wrote, saved as sent before the commit and posted after it."""
258
+ import json
259
+
260
+ from stackvitals.core import chat
261
+ from stackvitals.core.config import DAYS, parse
262
+ from stackvitals.core.decisions import Store
263
+ from stackvitals.core.time import iso
264
+ cfg = (ws.cache.get("settings") or parse("")[0])["digest"]
265
+ if not cfg["enabled"] or env.get("GITHUB_EVENT_NAME") not in EVENTS:
266
+ return None
267
+ due_slot = slot(now, DAYS.index(cfg["day"]), cfg["hour"])
268
+ path = ws.path / "digest" / "last.json"
269
+ last = _read(path)
270
+ if due_slot is None or (last or {}).get("week") == due_slot[0]:
271
+ return None
272
+ week_id = due_slot[0]
273
+ docs = {name: _read(ws.path / "docs" / file) for name, file in FILES}
274
+ if not any(docs.values()):
275
+ return None
276
+ if make_api is None:
277
+ from stackvitals.ci.api import ActionsAPI as make_api
278
+ api = make_api(env.get("STACKVITALS_TOKEN") or env.get("GITHUB_TOKEN"), ws.repo, max_requests=20)
279
+ try:
280
+ info = api._get(f"/repos/{ws.repo}")
281
+ except Exception: # noqa: BLE001 - without the link the digest still says everything
282
+ info = {}
283
+ owner, name = ws.repo.split("/")
284
+ app_url = f"https://{owner.lower()}.github.io/{name}/" if info.get("has_pages") and not info.get("private") else None
285
+ text, numbers = compose(ws.repo, due_slot[1], docs, last, due(Store(ws.path).state(now.date()), now.date()), app_url)
286
+ # Last week's pulse, counted from reactions in the digest issue (read before the commit; nothing about who answered).
287
+ pulse = cfg.get("pulse") and "issue" in cfg["channels"]
288
+ if pulse and last and last.get("week"):
289
+ from stackvitals.core.pulse import record, section, tally
290
+ try:
291
+ keep = None
292
+ if not info.get("private"):
293
+ # Anyone can react on a public repo: count only the team's own people.
294
+ from stackvitals.core.actors import People
295
+ from stackvitals.flow.store import FlowStore
296
+ flow = ws.cache.get("flow") or FlowStore(ws.path).load()
297
+ people = People((flow.get("closed") or []) + (flow.get("open") or []))
298
+ keep = lambda login: people.role(login) == "insider" # noqa: E731
299
+ counts = tally(api, _issue(api, ws.repo), last["week"], since=last.get("sent"), keep=keep)
300
+ record(ws.path, last["week"], counts) # the week's counts for the app's trend; never who answered
301
+ line = section(counts)
302
+ except Exception: # noqa: BLE001 - the digest goes out without it
303
+ line = None
304
+ if line:
305
+ text = f"{text}\n\n{line}"
306
+ path.parent.mkdir(parents=True, exist_ok=True)
307
+ path.write_text(json.dumps({"week": week_id, "sent": iso(now), "numbers": numbers}, indent=1), encoding="utf-8")
308
+ notes = []
309
+
310
+ def send():
311
+ if "issue" in cfg["channels"]:
312
+ try:
313
+ api.send("POST", f"/repos/{ws.repo}/issues/{_issue(api, ws.repo)}/comments", {"body": text})
314
+ if pulse:
315
+ from stackvitals.core.pulse import ask
316
+ ask(api, _issue(api, ws.repo), week_id)
317
+ except Exception as exc: # noqa: BLE001 - reported, never fails the job
318
+ notes.append(f"Could not post the weekly digest ({type(exc).__name__}); it needs `issues: write`.")
319
+ if "webhook" in cfg["channels"] and env.get("STACKVITALS_WEBHOOK"):
320
+ lines = [x for x in text.splitlines() if x.strip()]
321
+ body = [x for x in lines[1:] if not x.startswith("[Open StackVitals]")]
322
+ msg = chat.message(lines[0].lstrip("# ").strip(), body, [("Open StackVitals", app_url)] if app_url else [],
323
+ {"repo": ws.repo, "week": week_id})
324
+ failed = chat.post(env["STACKVITALS_WEBHOOK"], msg, client, "the digest webhook")
325
+ if failed:
326
+ notes.append(failed)
327
+ summary = env.get("GITHUB_STEP_SUMMARY")
328
+ if summary and notes:
329
+ with open(summary, "a", encoding="utf-8") as f:
330
+ f.write("### StackVitals digest\n" + "\n".join(notes) + "\n")
331
+ if not os.environ.get("STACKVITALS_READ_ONLY"): # a local read-only run sends nothing anywhere
332
+ ws.after_commit(send, "Digest")
333
+ return {"week": week_id, "channels": list(cfg["channels"])}
@@ -0,0 +1,107 @@
1
+ """`stackvitals explain owner/repo lead-time`: a headline number, what it means, and the pull requests, deployments or
2
+ pushes it was counted from, each with its GitHub link. Reads the repo's published files (a local copy of its
3
+ stackvitals-data branch, as `stackvitals ui` keeps, or a docs folder with --docs)."""
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ PHASES = ("coding", "pickup", "review", "approve", "merge", "deploy", "cycle", "cycle-to-merge")
10
+ NUMBERS = ("deployments", "lead-time", "change-failure", "recovery", "first-try", *PHASES)
11
+
12
+
13
+ def _hours(minutes) -> str:
14
+ return "?" if minutes is None else f"{minutes / 60:.1f} h"
15
+
16
+
17
+ def _pct(x) -> str:
18
+ return "?" if x is None else f"{round(x * 100)}%"
19
+
20
+
21
+ def explain(repo: str, what: str, delivery: dict | None, data: dict | None) -> dict | None:
22
+ """{"headline", "meaning", "items": [{..., "url"}], "total"} for one number, or None when it isn't published."""
23
+ gh = f"https://github.com/{repo}"
24
+ dora = (delivery or {}).get("dora") or {}
25
+ ev = dora.get("evidence") or {}
26
+ if what == "deployments":
27
+ rows = ev.get("deployments") or []
28
+ return {"headline": f"Deployments: {dora.get('deployments', len(rows))} in {(delivery or {}).get('window_days', '?')} days",
29
+ "meaning": "Every deployment counted, oldest first; deployment frequency counts the days they landed on.",
30
+ "items": [{"commit": c, "finished": t, "url": f"{gh}/commit/{c}"} for c, t in rows], "total": len(rows)}
31
+ if what == "lead-time":
32
+ rows = ev.get("lead_time") or []
33
+ return {"headline": f"Change lead time: {dora.get('lead_time_hours', '?')} h (median of {dora.get('lead_time_n', len(rows))} deployed pull requests)",
34
+ "meaning": "Each deployed pull request with its time from first commit to deployment, longest first.",
35
+ "items": [{"pr": n, "deployment": c, "time": _hours(m), "url": f"{gh}/pull/{n}"} for n, c, m in rows],
36
+ "total": ev.get("lead_time_total", len(rows))}
37
+ if what in ("change-failure", "recovery"):
38
+ key = "failed" if what == "change-failure" else "recovery"
39
+ rows = ev.get(key) or []
40
+ head = (f"Change fail rate: {_pct(dora.get('change_failure_rate'))} ({dora.get('failed_deployments', len(rows))} of {dora.get('deployments', '?')} deployments)"
41
+ if key == "failed" else f"Failed deployment recovery: {dora.get('recovery_hours', '?')} h (median of {dora.get('recovery_n', len(rows))})")
42
+ kind = lambda s: "issues" if s == "issue" else "pull" # noqa: E731
43
+ items = [{"deployment": r[0], "incident": f"{r[1]} #{r[2]}", **({"time": _hours(r[3])} if key == "recovery" else {}),
44
+ "url": f"{gh}/{kind(r[1])}/{r[2]}" if r[2] and r[1] in ("issue", "revert", "hotfix") else f"{gh}/commit/{r[0]}"} for r in rows]
45
+ return {"headline": head, "meaning": "Each deployment tied to an incident after it (a revert, a hotfix or an incident issue)"
46
+ + ("." if key == "failed" else ", with the time until it was resolved."), "items": items, "total": len(rows)}
47
+ if what == "first-try":
48
+ p = (data or {}).get("pushes") or {}
49
+ rows = p.get("not_first_try") or []
50
+ return {"headline": f"Green on the first try: {_pct(p.get('first_try_share'))} of {p.get('pushes', '?')} pushes",
51
+ "meaning": "The pushes whose checks failed or needed a re-run, newest first, with those runs.",
52
+ "items": [{"commit": c, "branch": b, "runs": ids, "url": f"{gh}/actions/runs/{ids[0]}" if ids else f"{gh}/commit/{c}"}
53
+ for c, b, ids in rows], "total": p.get("not_first_try_total", len(rows))}
54
+ if what in PHASES:
55
+ ph = ((delivery or {}).get("phases") or {}).get(what.replace("-", "_")) or {}
56
+ rows = ph.get("evidence") or []
57
+ p75 = f"P75 {ph['p75_h']} h" if ph.get("p75_h") is not None else "too few to read a P75"
58
+ return {"headline": f"{what.replace('-', ' ').capitalize()}: {p75} over {ph.get('n', len(rows))} merged pull requests",
59
+ "meaning": "Each merged pull request's hours in this phase, longest first.",
60
+ "items": [{"pr": n, "time": f"{h:.1f} h", "url": f"{gh}/pull/{n}"} for n, h in rows], "total": ph.get("n", len(rows))}
61
+ return None
62
+
63
+
64
+ def _read(docs: Path, name: str) -> dict | None:
65
+ try:
66
+ return json.loads((docs / name).read_text(encoding="utf-8"))
67
+ except (OSError, ValueError):
68
+ return None
69
+
70
+
71
+ def main(argv: list[str] | None = None) -> int:
72
+ ap = argparse.ArgumentParser(prog="stackvitals explain", description=__doc__.split("\n\n")[0])
73
+ ap.add_argument("repo", help="owner/name")
74
+ ap.add_argument("number", help=", ".join(NUMBERS))
75
+ ap.add_argument("--docs", help="a docs folder to read instead of the repo's data branch")
76
+ ap.add_argument("--limit", type=int, default=20, help="items to print (default 20)")
77
+ ap.add_argument("--json", action="store_true")
78
+ args = ap.parse_args(argv)
79
+ if args.number not in NUMBERS:
80
+ print(f"Can't explain {args.number!r}. Pick one of: {', '.join(NUMBERS)}.", file=sys.stderr)
81
+ return 2
82
+ if args.docs:
83
+ docs = Path(args.docs)
84
+ else:
85
+ from stackvitals.ci import cli as ci_cli
86
+ from stackvitals.core.uicli import fetch
87
+ folder = Path(ci_cli.home()) / "ui" / args.repo.replace("/", "__")
88
+ try:
89
+ docs = fetch(args.repo, ci_cli.token(), folder) / "docs"
90
+ except RuntimeError as exc:
91
+ print(f"Could not read {args.repo}: {exc}", file=sys.stderr)
92
+ return 1
93
+ out = explain(args.repo, args.number, _read(docs, "delivery.json"), _read(docs, "data.json"))
94
+ if not out or not out["items"]:
95
+ print(f"{args.repo} hasn't published what's behind {args.number} yet (the next scheduled run writes it).", file=sys.stderr)
96
+ return 1
97
+ if args.json:
98
+ print(json.dumps(out, indent=1))
99
+ return 0
100
+ shown = out["items"][:args.limit]
101
+ print(out["headline"])
102
+ print(out["meaning"])
103
+ for it in shown:
104
+ print(" " + " ".join(f"#{v}" if k == "pr" else str(v) for k, v in it.items() if k != "url") + f" {it['url']}")
105
+ if out["total"] > len(shown):
106
+ print(f"({len(shown)} of {out['total']} shown; --limit for more, --json for all published)")
107
+ return 0