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,98 @@
1
+ """Where a repo's delivery stands among similar public repos: its own P75s measured with exactly the crawl benchmarks'
2
+ definitions (people's PRs opened in the last 90 days; pickup from opening to the first maintainer review), placed in the
3
+ closest group with enough repos, with the median's range, LinearB's band shares, and what the comparison can't see."""
4
+ import math
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+
8
+ from mergescout.bench.ci import tier
9
+ from stackvitals.ci import bench as ci_bench
10
+ from stackvitals.ci.bench import RANKS, _about, _label, _rank, fit
11
+ from stackvitals.core.actors import People
12
+ from stackvitals.flow.analyze import _human_reviews, _iso, _secs, is_bot
13
+
14
+ DELIVERY_FILE = "delivery-benchmarks.json"
15
+ MIN_PRS = 10
16
+ MIN_VALUES = 5
17
+ METRICS = [("pickup_p75_hours", "Pickup: opened to first maintainer review (P75)"),
18
+ ("review_p75_hours", "Review: first review to merge (P75)"),
19
+ ("cycle_to_merge_p75_hours", "Opened to merged (P75)")]
20
+
21
+
22
+ def _p75(values: list[float]) -> float | None:
23
+ if len(values) < MIN_VALUES:
24
+ return None
25
+ s = sorted(values)
26
+ return round(s[min(len(s) - 1, max(0, math.ceil(0.75 * len(s)) - 1))], 4)
27
+
28
+
29
+ def own_metrics(prs: list[dict], now: datetime, window_days: int = 90, people: People | None = None) -> dict | None:
30
+ """This repo's P75s by the crawl's rules, so the comparison is like with like."""
31
+ since = _iso(now - timedelta(days=window_days))
32
+ people = people or People(prs)
33
+ mine = [p for p in prs if p["cr"] >= since and not is_bot(p)]
34
+ if len(mine) < MIN_PRS:
35
+ return None
36
+ pickup, review, cycle = [], [], []
37
+ for p in mine:
38
+ humans = _human_reviews(p)
39
+ first = humans[0] if humans and people.is_insider(humans[0][2]) and humans[0][2] != p.get("au") else None
40
+ if first and (wait := _secs(p["cr"], first[1])) is not None:
41
+ pickup.append(wait / 3600)
42
+ if p.get("mg"):
43
+ if first and (wait := _secs(first[1], p["mg"])) is not None:
44
+ review.append(wait / 3600)
45
+ if (wait := _secs(p["cr"], p["mg"])) is not None:
46
+ cycle.append(wait / 3600)
47
+ return {"prs": len(mine), "pickup_p75_hours": _p75(pickup), "review_p75_hours": _p75(review),
48
+ "cycle_to_merge_p75_hours": _p75(cycle)}
49
+
50
+
51
+ def _ok(q) -> bool:
52
+ return isinstance(q, dict) and all(isinstance(q.get(f"p{int(r * 100)}"), (int, float)) for r in RANKS)
53
+
54
+
55
+ def valid(data) -> dict | None:
56
+ # A bad file is ignored, metric by metric, so it can neither break a run nor reach a page.
57
+ if not isinstance(data, dict) or not isinstance(data.get("groups"), dict):
58
+ return None
59
+ groups = {}
60
+ for key, g in data["groups"].items():
61
+ if isinstance(key, str) and key.count("|") == 1 and isinstance(g, dict) and isinstance(g.get("repos"), int):
62
+ kept = {m: g[m] for m, _ in METRICS if _ok(g.get(m))}
63
+ if kept:
64
+ groups[key] = {"repos": g["repos"], **kept}
65
+ return {**data, "groups": groups} if groups else None
66
+
67
+
68
+ def load(token: str | None, cache_dir: Path, client=None) -> dict | None:
69
+ return ci_bench.load(token, cache_dir, client=client, name=DELIVERY_FILE, url_env="STACKVITALS_DELIVERY_BENCH_URL",
70
+ validate=valid, default_url=None)
71
+
72
+
73
+ def compare(own: dict | None, bench: dict | None, language: str | None, stars: int | None) -> dict | None:
74
+ """The closest group with the metric (language and size, then wider), and this repo's place in it."""
75
+ bench = valid(bench) if bench else None
76
+ if not own or not bench:
77
+ return None
78
+ groups, size = bench["groups"], tier(stars)
79
+ metrics, chosen = [], None
80
+ for name, label in METRICS:
81
+ if own.get(name) is None:
82
+ continue
83
+ key = next((k for k in (f"{language}|{size}", f"{language}|all", f"all|{size}", "all|all")
84
+ if k in groups and name in groups[k]), None)
85
+ if key is None:
86
+ continue
87
+ chosen = chosen or key
88
+ q = groups[key][name]
89
+ r = _rank(own[name], q)
90
+ place = f"slower than about {_about(r)}%" if r >= 0.5 else f"faster than about {_about(1 - r)}%"
91
+ metrics.append({"key": name, "name": label, "you": own[name], "typical": q["p50"], "low": q.get("p50_low"),
92
+ "high": q.get("p50_high"), "p25": q["p25"], "p75": q["p75"], "place": place, "repos": q["repos"],
93
+ "group": _label(key), "linearb": q.get("linearb")})
94
+ if not metrics:
95
+ return None
96
+ return {"group": _label(chosen), "repos": groups[chosen]["repos"], "built_at": bench.get("built_at"), "metrics": metrics,
97
+ "fit": fit(chosen, language, size),
98
+ "population": bench.get("population"), "gaps": bench.get("gaps")}
@@ -0,0 +1,429 @@
1
+ """The Delivery page, the job summary section and the Action step that writes them from the job's checkout."""
2
+ import html
3
+ import json
4
+ from datetime import timedelta
5
+
6
+ from stackvitals import shell
7
+ from stackvitals.ci.bench import PEERS_ARE
8
+ from stackvitals.core.time import iso
9
+
10
+ NAMES = {"coding": "Coding", "pickup": "Pickup", "review": "Review", "approve": "Approve", "merge": "Merge",
11
+ "deploy": "Deploy", "cycle": "Cycle time", "cycle_to_merge": "Cycle time to merge"}
12
+ HELP = {"coding": "first commit to opened", "pickup": "ready for review to the first other person's comment or review",
13
+ "review": "first comment or review to merge", "approve": "first comment or review to first approval",
14
+ "merge": "first approval to merge", "deploy": "merge to the deployment that shipped it",
15
+ "cycle": "coding + pickup + review + deploy", "cycle_to_merge": "coding + pickup + review"}
16
+ STUCK = {"hanging_review": "Hanging review (asked more than 3 days ago, no review yet)",
17
+ "long_lived": "Long-lived (ready for more than 30 days)", "high_interaction": "High interaction (10+ comments or reviews)",
18
+ "red_and_idle": "Red and idle (failing checks nobody acted on for a day)"}
19
+ DORA_TEXT = {"elite": "elite", "high": "high", "medium": "medium", "low": "low", None: "not enough data"}
20
+
21
+
22
+ def _h(hours) -> str:
23
+ if hours is None:
24
+ return "-"
25
+ if hours >= 48:
26
+ return f"{hours / 24:.1f} days"
27
+ return f"{hours:.1f} h" if hours >= 1 else f"{hours * 60:.0f} min"
28
+
29
+
30
+ def _pct(x) -> str:
31
+ return "-" if x is None else f"{x:.0%}"
32
+
33
+
34
+ SURE = {"high": "sure: GitHub deployments to production", "medium": "fairly sure: published versions",
35
+ "low": "a guess from workflow names"}
36
+
37
+
38
+ TRACKERS = {"jira": "Jira", "linear": "Linear"}
39
+ ISSUE_TYPES = {"REQUIREMENT": "Stories and features", "BUG": "Bugs", "TASK": "Tasks", "INCIDENT": "Incidents"}
40
+
41
+
42
+ def _copilot_line(c: dict) -> str:
43
+ e = html.escape
44
+ return (f"<li>Copilot in {e(str(c['org']))}, last {c['window_days']} days (GitHub's own reports, {c['days_kept']} days kept "
45
+ f"since {e(c['first_day'])}): {c['monthly_active_users'] or 0} monthly active users, {_pct(c['acceptance'])} of "
46
+ f"suggestions accepted, {c['org_prs_by_copilot']} pull requests by Copilot across the org "
47
+ f"({c['org_prs_merged_by_copilot']} merged), {c['repo_prs_by_copilot']} in this repo.</li>")
48
+
49
+
50
+ def _points(x: dict) -> str:
51
+ return "-" if x["points_scope"] is None else f"{x['points_done']:g} of {x['points_scope']:g}"
52
+
53
+
54
+ def _incident_link(i: dict, repo: str) -> str:
55
+ e = html.escape
56
+ if str(i.get("url") or "").startswith("https://"):
57
+ return f"<a href='{e(i['url'])}'>{e(str(i['number']))}</a>"
58
+ kind = "issues" if i["source"] == "issue" else "pull"
59
+ return f"<a href='https://github.com/{e(repo)}/{kind}/{int(i['number'])}'>#{int(i['number'])}</a>"
60
+
61
+
62
+ def render_html(r: dict, repo: str, pages=()) -> str:
63
+ e = html.escape
64
+ d = r["dora"]
65
+ sub = (f"Last {r['window_days']} days, updated {e(r['generated'])}. Deployments from: {e(r['deploy_source'] or 'none found')}"
66
+ f"{' (' + SURE[r.get('deploy_confidence')] + ')' if r.get('deploy_confidence') else ''}. "
67
+ f"DORA tiers: DevLake's {e(d['report'])} tables, counted as its DORA dashboard counts them.")
68
+ if r.get("deployments_unreadable"):
69
+ sub += " GitHub deployments could not be read: add `deployments: read` to the workflow's permissions to count them."
70
+ if r.get("deploy_confidence") == "low":
71
+ counted = ", ".join(f"{e(str(x['name']))} ({x['n']})" for x in r.get("deploy_names") or [])
72
+ sub += (f" Guessed from workflow names: {counted}. If these do not put code in front of users, set source = \"releases\" "
73
+ "or name the right workflow in deploy_workflow under [delivery] in .github/stackvitals.toml.")
74
+ body = [f"<h2>Cycle time</h2><p class=mut>{r['prs']} merged pull requests by people (bots and coding agents left out); "
75
+ f"{r['outliers']['over_60_days']} over 60 days and {r['outliers']['over_p98']} above the 98th percentile left out "
76
+ "(LinearB's outlier rule)"
77
+ + (f", and {r['excluded']} the team excluded" if r.get("excluded") else "")
78
+ + ". P75 is the number LinearB's bands use.</p><div class=tbl><table class=find><tr>"
79
+ "<th>Phase</th><th>What it measures</th><th>P50</th><th>P75</th><th>PRs</th><th>LinearB band</th></tr>"]
80
+ for name, label in NAMES.items():
81
+ s = r["phases"][name]
82
+ rng = f" <span class=mut>({_h(s['low_h'])}-{_h(s['high_h'])})</span>" if s["low_h"] is not None else ""
83
+ if not s.get("enough", True):
84
+ body.append(f"<tr><td>{label}</td><td class=mut>{HELP[name]}</td><td colspan=2>not enough data (under 10 PRs)</td>"
85
+ f"<td>n={s['n']}</td><td>-</td></tr>")
86
+ continue
87
+ body.append(f"<tr><td>{label}</td><td class=mut>{HELP[name]}</td><td>{_h(s['p50_h'])}{rng}</td><td>{_h(s['p75_h'])}</td>"
88
+ f"<td>n={s['n']}</td><td>{e(s['linearb_band'] or '-')}</td></tr>")
89
+ body.append("</table></div>")
90
+ for key, title, note in (("by_team", "By team", "Teams from [identity.teams] in .github/stackvitals.toml, by their members' PRs."),
91
+ ("by_owner", "By owning team", "Teams in CODEOWNERS, by the PRs touching their paths; single people are left out.")):
92
+ groups = r.get(key) or {}
93
+ if groups:
94
+ body.append(f"<h3>{title}</h3><p class=mut>{note}</p><div class=tbl><table class=find><tr><th>Team</th>"
95
+ "<th>Cycle time to merge P50</th><th>P75</th><th>Pickup P75</th><th>Review P75</th><th>PRs</th></tr>")
96
+ for team, g in groups.items():
97
+ c = g["cycle_to_merge"]
98
+ cells = (f"<td>{_h(c['p50_h'])}</td><td>{_h(c['p75_h'])}</td><td>{_h(g['pickup']['p75_h'])}</td>"
99
+ f"<td>{_h(g['review']['p75_h'])}</td>") if c.get("enough", True) else "<td colspan=4>not enough data</td>"
100
+ body.append(f"<tr><td>{e(team)}</td>{cells}<td>n={g['n']}</td></tr>")
101
+ body.append("</table></div>")
102
+ body.append("<h2>DORA</h2><div class=tbl><table><tr><th>Key</th><th>Value</th><th>Tier</th></tr>"
103
+ f"<tr><td>Deployment frequency</td><td>{d['deployment_days_per_week'] if d['deployment_days_per_week'] is not None else '-'} "
104
+ f"deployment days a week (median), {d['deployments']} deployments</td><td>{DORA_TEXT[d['frequency_tier']]}</td></tr>"
105
+ f"<tr><td>Lead time for changes</td><td>{_h(d['lead_time_hours'])} (median of {d['lead_time_n']} deployed PRs)</td>"
106
+ f"<td>{DORA_TEXT[d['lead_time_tier']]}</td></tr>"
107
+ f"<tr><td>Change failure rate</td><td>{_pct(d['change_failure_rate'])} ({d['failed_deployments']} of {d['deployments']} "
108
+ f"deployments had an incident)</td><td>{DORA_TEXT[d['cfr_tier']]}</td></tr>"
109
+ f"<tr><td>Failed deployment recovery</td><td>{_h(d['recovery_hours'])} (median of {d['recovery_n']})</td>"
110
+ f"<td>{DORA_TEXT[d['recovery_tier']]}</td></tr>"
111
+ f"<tr><td>Deployment rework rate</td><td>{_pct(d.get('rework_rate'))} ({d.get('rework_deployments', 0)} of "
112
+ f"{d.get('deployments_n', 0)} deployments shipped a revert or hotfix)</td><td>-</td></tr></table></div>"
113
+ f"<p class=mut>Incidents from {e(r.get('incident_source') or 'GitHub issues, reverts and hotfixes')}, each linked "
114
+ "to the last deployment before it. Change the labels, the deploy workflow pattern or the source in "
115
+ ".github/stackvitals.toml; a connected PagerDuty or incident.io is used instead of labels, reverts and hotfixes.</p>")
116
+ peers = r.get("peers")
117
+ if peers:
118
+ body.append(f"<h2>Compared with similar public repos</h2><p class=mut>{e(peers['group'])} ({peers['repos']} repos in "
119
+ f"the daily crawl{', ' + e(peers['fit']) if peers.get('fit') else ''}; {PEERS_ARE}, so they may not resemble a private "
120
+ "team's work). Measured the crawl's way for both: people's PRs opened in the last 90 days, pickup "
121
+ "from opening.</p><div class=tbl><table><tr><th>Measure</th><th>This repo</th><th>Typical (range)</th>"
122
+ "<th>Middle half</th><th>Place</th><th>Inside LinearB's good band</th></tr>")
123
+ for m in peers["metrics"]:
124
+ rng = f" ({_h(m['low'])}-{_h(m['high'])})" if m.get("low") is not None else ""
125
+ good = f"{m['linearb']['good']:.0%} of them" if m.get("linearb") else "-"
126
+ body.append(f"<tr><td>{e(m['name'])}</td><td>{_h(m['you'])}</td><td>{_h(m['typical'])}{rng}</td>"
127
+ f"<td>{_h(m['p25'])}-{_h(m['p75'])}</td><td>{e(m['place'])} (n={m['repos']})</td><td>{good}</td></tr>")
128
+ body.append("</table></div>")
129
+ if peers.get("population") or peers.get("gaps"):
130
+ body.append(f"<p class=mut>Who is compared: {e(peers.get('population') or '-')}. Not covered: "
131
+ f"{e(peers.get('gaps') or '-')}.</p>")
132
+ agreed = r.get("agreements")
133
+ if agreed:
134
+ body.append(f"<h2>Working agreements</h2><p class=mut>The team's own rules (under [agreements] in .github/stackvitals.toml), "
135
+ f"over the last {agreed['window_days']} days: {agreed['merged']} merged pull requests, plus open ones already "
136
+ "past a time limit.</p><div class=tbl><table class=find><tr><th data-filter>Agreement</th><th>Broken by</th></tr>")
137
+ for rule, label in agreed["labels"].items():
138
+ prs = [x for x in agreed["exceptions"] if x["rule"] == rule]
139
+ links = ", ".join(f"<a href='https://github.com/{e(repo)}/pull/{int(x['pr'])}'>#{int(x['pr'])}</a>"
140
+ + (" (open)" if x["open"] else "") for x in prs[:20]) or "none"
141
+ body.append(f"<tr><td>{e(label)}</td><td>{agreed['counts'][rule]}: {links}</td></tr>")
142
+ body.append("</table></div>")
143
+ body.append("<h2>Stuck pull requests</h2>")
144
+ any_stuck = False
145
+ for key, label in STUCK.items():
146
+ items = r["stuck"].get(key) or []
147
+ if items:
148
+ any_stuck = True
149
+ body.append(f"<h3>{e(label)}: {len(items)}</h3><ul>" + "".join(
150
+ f"<li><a href='https://github.com/{e(repo)}/pull/{int(x['pr'])}'>#{int(x['pr'])}</a>"
151
+ + (f" for {_h(x['hours'])}" if x.get("hours") is not None else "") + "</li>" for x in items[:30]) + "</ul>")
152
+ if not any_stuck:
153
+ body.append("<p>None.</p>")
154
+ sz = r["sizes"]
155
+ body.append(f"<h2>Pull request size</h2><p>Lines added plus removed, merged PRs (n={sz['n']}): under 100: {sz['under_100']}; "
156
+ f"100-155: {sz['100_155']}; 156-228: {sz['156_228']}; over 228: {sz['over_228']} (LinearB's bands). "
157
+ f"Median {sz['p50'] if sz['p50'] is not None else '-'} lines.</p>"
158
+ f"<p>Merged without any review or comment from someone else: {r['merged_without_review']['n']} "
159
+ f"({_pct(r['merged_without_review']['share'])}). Reviewed in under 20 minutes: {len(r['merged_flags']['lightning'])}. "
160
+ f"Review depth: {r['review_depth'] if r['review_depth'] is not None else '-'} comments and reviews per merged PR. "
161
+ f"Merge frequency: {r['merge_frequency'] if r['merge_frequency'] is not None else '-'} merged PRs per author per week.</p>")
162
+ if r["incidents"]:
163
+ body.append("<h2>Incidents</h2><div class=tbl><table class=find><tr><th data-filter>Source</th><th>Item</th><th>Opened</th>"
164
+ "<th>After deployment</th><th>Restored in</th></tr>" + "".join(
165
+ f"<tr><td>{e(i['source'])}</td><td>{_incident_link(i, repo)}</td>"
166
+ f"<td>{e(i['created'][:10])}</td><td>{e(i['deployment'] or 'none before it')}</td>"
167
+ f"<td>{_h(i['hours_to_restore'])}</td></tr>" for i in r["incidents"]) + "</table></div>")
168
+ q = r.get("merge_queue")
169
+ if q:
170
+ removed = ", ".join(f"{e(str(k))} {v}" for k, v in q["removals"].items()) or "none"
171
+ body.append(f"<h2>Merge queue</h2><p>{q['prs']} pull requests went through the merge queue in the last {q['window_days']} "
172
+ f"days; {q['merged']} merged, {_h(q['p50_h'])} from entering the queue to merge at the median and "
173
+ f"{_h(q['p90_h'])} for 9 in 10. Taken out of the queue: {removed}. Merge-group runs: {q['runs']}, "
174
+ f"{q['failed_runs']} failed.</p>")
175
+ if q["removed_by_flakes"]:
176
+ body.append("<p class=mut>Taken out after a merge-group run that failed only on jobs known to be flaky:</p><ul>" + "".join(
177
+ f"<li><a href='https://github.com/{e(repo)}/pull/{int(x['pr'])}'>#{int(x['pr'])}</a> on {e(x['at'][:10])} "
178
+ f"({e(', '.join(x['jobs']))})</li>" for x in q["removed_by_flakes"][:20]) + "</ul>")
179
+ inv = r.get("investment")
180
+ if inv:
181
+ cats = list(inv["counts"])
182
+ body.append(f"<h2>Where the work went</h2><p>{_mix(inv)}.</p><p class=mut>Merged PRs by people and coding agents since "
183
+ f"{e(inv['since'])}, sorted by the repo's own label rules, then a security label, the linked issue's type, "
184
+ "common labels, a changelog fragment, the title's prefix, the files changed (only docs, tests or CI) and "
185
+ f"the title's first verb; {inv['bots']} update-bot PRs counted apart. Decided by "
186
+ + ", ".join(f"{e(k)} {v}" for k, v in inv["decided_by"].items()) + ".</p>"
187
+ "<div class=tbl><table><tr><th>Month</th>" + "".join(f"<th>{e(c)}</th>" for c in cats) + "<th>All</th></tr>"
188
+ + "".join(f"<tr><td>{e(m['month'])}</td>" + "".join(f"<td>{m['counts'][c]}</td>" for c in cats)
189
+ + f"<td>{m['n']}</td></tr>" for m in inv["months"]) + "</table></div>")
190
+ t = r.get("tracker")
191
+ if t:
192
+ names = " and ".join(TRACKERS.get(x, x) for x in t["sources"])
193
+ body.append(f"<h2>Issues from {e(names)}</h2><p class=mut>{t['issues']} issues read; {t['linked_prs']} of "
194
+ f"{t['merged_prs']} merged pull requests name one in their title or branch. Lead time runs from created to "
195
+ "done, cycle time from the first move to an in-progress status; canceled issues are left out.</p>"
196
+ "<div class=tbl><table><tr><th>Type</th><th>Done</th><th>Lead time P50</th><th>P75</th>"
197
+ "<th>Cycle time P50</th><th>P75</th></tr>" + "".join(
198
+ f"<tr><td>{ISSUE_TYPES.get(k, e(k))}</td><td>{v['n']}</td><td>{_h(v['lead_p50_h'])}</td>"
199
+ f"<td>{_h(v['lead_p75_h'])}</td><td>{_h(v['cycle_p50_h'])}</td><td>{_h(v['cycle_p75_h'])}</td></tr>"
200
+ for k, v in t["lead_time"].items()) + "</table></div>")
201
+ if t["planning"]:
202
+ body.append("<h3>Sprints and cycles</h3><p class=mut>Done means finished by the sprint's end; carried over means "
203
+ "still open when it closed; added after start means moved in once it had begun.</p><div class=tbl>"
204
+ "<table><tr><th>Sprint</th><th>State</th><th>Scope</th><th>Done</th><th>Carried over</th>"
205
+ "<th>Added after start</th><th>Points done</th></tr>" + "".join(
206
+ f"<tr><td>{e(str(x['name']))}</td><td>{e(x['state'].lower())}</td><td>{x['scope']}</td>"
207
+ f"<td>{x['done']}</td><td>{x['carried_over']}</td><td>{x['added_after_start']}</td>"
208
+ f"<td>{_points(x)}</td></tr>"
209
+ for x in reversed(t["planning"])) + "</table></div>")
210
+ ai = r["ai"]
211
+ a, s = ai["agent_prs"], ai["assisted_prs"]
212
+ tools = ", ".join(f"{e(k)} {v}" for k, v in sorted(s["tools"].items())) or "none"
213
+ body.append("<h2>AI and coding agents</h2><p class=mut>Totals only, never a person (an association, not a cause). AI help is "
214
+ "read from co-author trailers in a PR's last five commits and from labels coding tools add.</p><ul>"
215
+ f"<li>Pull requests by coding agents: {a['n']}, merged {_pct(a['merged_share'])}.</li>"
216
+ f"<li>People's merged PRs with AI help: {s['n']} ({_pct(s['share'])}; {tools}); pickup P75 "
217
+ f"{_h(s['pickup_p75_h'])} against {_h(s['others_pickup_p75_h'])} for the rest; size P75 {s['size_p75'] or '-'} lines "
218
+ f"against {s['others_size_p75'] or '-'}.</li>"
219
+ f"<li>Merged PRs reviewed by an AI review bot: {ai['ai_reviewed_prs']['n']}.</li>"
220
+ + (f"<li>In {e(ai['baseline']['group'])} ({ai['baseline']['repos']} repos in the daily crawl, last 6 months), the "
221
+ f"typical repo merges {_pct(ai['baseline']['agent_merged'])} of coding agents' PRs and "
222
+ f"{_pct(ai['baseline']['outsider_merged'])} of outside contributors' PRs.</li>" if ai.get("baseline") else "")
223
+ + (_copilot_line(ai["copilot"]) if ai.get("copilot") else "") + "</ul>")
224
+ out = ai.get("outcomes") or {}
225
+ w, matched = out.get("window"), out.get("matched") or {}
226
+ if w and (w.get("assisted", {}).get("n") or w.get("agent", {}).get("n")):
227
+ said = [f"<li>{e(label)}: {e(text)}</li>" for label, text in _matched_lines(matched)]
228
+ peers = ((ai.get("baseline") or {}).get("matched") or {}).get("merged_by_others")
229
+ if peers:
230
+ pts = lambda x: f"{x * 100:+.0f}" # noqa: E731
231
+ said.append(f"<li>Similar public repos ({ai['baseline']['matched']['repos']} {e(ai['baseline']['group'])}): coding "
232
+ f"agents' PRs merged by someone else {pts(peers['gap'])} points (likely {pts(peers['low'])} to "
233
+ f"{pts(peers['high'])}) against people's PRs of the same repo, month and size</li>")
234
+ if said:
235
+ body.append("<h3>Like with like</h3><p class=mut>A person's AI-marked PRs against the same person's other PRs of "
236
+ "a similar size; coding agents' PRs against people's PRs of the same month and size. Each says the "
237
+ "gap it could detect when it finds none.</p><ul>" + "".join(said) + "</ul>")
238
+ rows = (("Merged PRs", "n", str), ("Merge rate (merged of merged and closed)", "merge_rate", _pct),
239
+ ("Pickup P75", "pickup_p75_h", _h), ("Review P75", "review_p75_h", _h),
240
+ ("Size P75 (lines)", "size_p75", lambda x: "-" if x is None else f"{x:g}"),
241
+ ("Merged since the CI history began", "ci_n", lambda x: "-" if x is None else str(x)),
242
+ ("A CI run on the PR failed", "ci_failed_share", _pct),
243
+ ("A failed run passed on re-run (flaky)", "flaky_share", _pct), ("Reverted later", "reverted_share", _pct),
244
+ ("Shipped in a deployment with an incident", "failed_deploy_share", _pct))
245
+ side = lambda k: w.get(k) or {} # noqa: E731
246
+ body.append("<h3>After merge: raw shares, not matched</h3><p class=mut>The last 6 months of merged PRs. CI shares "
247
+ "count PRs merged since the CI history began; failed deployments count PRs in deployments of up to 5 PRs, "
248
+ "where an incident can be pinned near a PR.</p>"
249
+ "<div class=tbl><table><tr><th>Measure</th><th>Coding agents</th><th>AI-marked</th><th>Others</th></tr>"
250
+ + "".join(f"<tr><td>{label}</td><td>{fmt(side('agent').get(k))}</td><td>{fmt(side('assisted').get(k))}</td>"
251
+ f"<td>{fmt(side('others').get(k))}</td></tr>" for label, k, fmt in rows) + "</table></div>")
252
+ return shell.page("Delivery", repo, "delivery.html", sub, "".join(body), pages)
253
+
254
+
255
+ OUTCOME_WORDS = {"reverted": "reverted", "ci_failed": "a CI run failed", "failed_deploy": "shipped in a failed deployment",
256
+ "merged": "merged", "merged_by_others": "merged by someone else"}
257
+
258
+
259
+ def _matched_lines(matched: dict) -> list[tuple[str, str]]:
260
+ # (who, what) for each like-for-like comparison with enough matched PRs.
261
+ out = []
262
+ for side, who in (("assisted", "AI-marked PRs against the same authors' other PRs"),
263
+ ("agent", "Coding agents' PRs against people's of the same month and size")):
264
+ for key, words in OUTCOME_WORDS.items():
265
+ c = (matched.get(side) or {}).get(key) or {}
266
+ if c.get("gap") is None:
267
+ continue
268
+ if c["verdict"] == "no measurable difference":
269
+ text = f"{words}: no measurable difference (could detect {round(c['detectable'] * 100)} points or more)"
270
+ else:
271
+ pts = lambda x: f"{x * 100:+.0f}" # noqa: E731
272
+ text = (f"{words}: {'more' if c['verdict'] == 'higher' else 'less'} often, {pts(c['gap'])} points (likely "
273
+ f"{pts(c['low'])} to {pts(c['high'])}), {c['matched']} of {c['of']} PRs matched")
274
+ out.append((who, text))
275
+ return out
276
+
277
+
278
+ def render_markdown(r: dict, repo: str) -> str:
279
+ d, ph = r["dora"], r["phases"]
280
+ stuck = ", ".join(f"{STUCK[k].split(' (')[0].lower()} {len(v)}" for k, v in r["stuck"].items() if v) or "none"
281
+ return "\n".join([
282
+ f"### StackVitals Delivery: {repo.replace('@', '')}",
283
+ f"{r['prs']} merged PRs in {r['window_days']} days. Cycle time to merge P75 {_h(ph['cycle_to_merge']['p75_h'])}; "
284
+ f"pickup P75 {_h(ph['pickup']['p75_h'])}; review P75 {_h(ph['review']['p75_h'])}.",
285
+ f"DORA ({d['report']}, deployments from {r['deploy_source'] or 'none found'}): {d['deployment_days_per_week'] if d['deployment_days_per_week'] is not None else '-'} "
286
+ f"deployment days a week ({DORA_TEXT[d['frequency_tier']]}); lead time {_h(d['lead_time_hours'])} ({DORA_TEXT[d['lead_time_tier']]}); "
287
+ f"change failure rate {_pct(d['change_failure_rate'])} ({DORA_TEXT[d['cfr_tier']]}); recovery {_h(d['recovery_hours'])} "
288
+ f"({DORA_TEXT[d['recovery_tier']]}); rework rate {_pct(d.get('rework_rate'))}.",
289
+ f"Stuck PRs: {stuck}.",
290
+ *([f"Where the work went ({r['investment']['n']} merged PRs since {r['investment']['since']}): {_mix(r['investment'])}."]
291
+ if r.get("investment") else []), ""])
292
+
293
+
294
+ def _mix(inv: dict) -> str:
295
+ return ", ".join(f"{k} {_pct(v)}" for k, v in sorted(inv["shares"].items(), key=lambda kv: -kv[1]) if v)
296
+
297
+
298
+ STUCK_LABEL = "stackvitals: waiting for review"
299
+
300
+
301
+ def label_stuck_prs(api, out: dict, open_prs: list[dict]) -> dict:
302
+ """Opt-in: label the PRs whose asked-for review has not started after 3 days, and take the label off once one has."""
303
+ from urllib.parse import quote
304
+ hanging = {x["pr"] for x in out["stuck"]["hanging_review"]}
305
+ labelled = {p["n"] for p in open_prs if STUCK_LABEL in (p.get("lb") or [])}
306
+ for n in sorted(hanging - labelled):
307
+ api.send("POST", f"/repos/{api.repo}/issues/{n}/labels", {"labels": [STUCK_LABEL]})
308
+ for n in sorted(labelled - hanging):
309
+ api.send("DELETE", f"/repos/{api.repo}/issues/{n}/labels/{quote(STUCK_LABEL)}")
310
+ return {"added": len(hanging - labelled), "removed": len(labelled - hanging)}
311
+
312
+
313
+ def delivery_step(ws, env: dict, now, window_days: int = 90, label_stuck: bool = False, make_api=None) -> dict | None:
314
+ """The Action's Delivery page, from Team Flow's PRs, CI Health's runs and Community's issues in the checkout. Runs when
315
+ Team Flow ran in the same job (the schedule)."""
316
+ from stackvitals.ci.store import DataStore
317
+ from stackvitals.core.config import parse
318
+ from stackvitals.core.delivery import build
319
+ from stackvitals.core.store import published, write_if_changed
320
+ from stackvitals.flow.store import FlowStore
321
+ if ws.results.get("Team Flow") is None:
322
+ return None
323
+ store = FlowStore(ws.path)
324
+ from stackvitals.core.decisions import visible
325
+ flow = visible(ws.path, ws.cache.get("flow") or store.load(), now.date())
326
+ ci = ws.cache.get("ci") or (DataStore(ws.path).load() if (ws.path / "ci" / "days").is_dir() else {"runs": [], "jobs": []})
327
+ issues = []
328
+ if (ws.path / "community" / "issues").is_dir():
329
+ found = FlowStore(ws.path, "community/issues").load()
330
+ issues = found["open"] + found["closed"]
331
+ cfg = ws.cache.get("settings") or parse("")[0]
332
+ info = store.repo()
333
+ from stackvitals.connectors import active, public
334
+ from stackvitals.connectors import load as load_tracker
335
+ # What the connectors with a key pulled (this job or earlier ones); a public repo's never carry titles.
336
+ tracker = ws.cache.get("tracker") or load_tracker(ws.path, redact=public(ws), names=active(env, cfg))
337
+ out = build(flow, info, ci, issues, now, window_days, cfg, tracker=tracker)
338
+ from stackvitals.core.agent_ledger import agent_of
339
+ from stackvitals.flow import survival
340
+ try:
341
+ lasted = json.loads((store.dir / survival.FILE).read_text(encoding="utf-8"))
342
+ except (OSError, ValueError):
343
+ lasted = {}
344
+ # Lines of people's and agents' PRs still in the default branch 30 and 90 days later.
345
+ out["code_survival"] = survival.summary(lasted, flow["closed"], lambda p: "agent" if agent_of(p) else "person")
346
+ from stackvitals.core.merge_queue import health as queue_health
347
+ try:
348
+ jobs = json.loads((ws.path / "docs" / "data.json").read_text(encoding="utf-8")).get("jobs") or []
349
+ except (OSError, ValueError):
350
+ jobs = []
351
+ # How long the merge queue holds pull requests, and which removals a known flaky job caused (CI Health's states).
352
+ out["merge_queue"] = queue_health(flow["open"] + flow["closed"], ci, {(j.get("workflow"), j.get("job")): j.get("state")
353
+ for j in jobs}, now, window_days)
354
+ from stackvitals.connectors import copilot_summary
355
+ out["ai"]["copilot"] = copilot_summary(ws.path, now) # the org's Copilot reports, kept day by day, when connected
356
+ from stackvitals.connectors.ai_spend import summary as spend_summary
357
+ from stackvitals.core.ai_outcomes import mode
358
+ # What the AI tools cost in the last 30 days, beside the agents' and AI-marked PRs merged in the same days.
359
+ month = iso(now - timedelta(days=30))
360
+ out["ai"]["spend"] = spend_summary(ws.path, now, sum(1 for p in flow["closed"] if (p.get("mg") or "") >= month
361
+ and mode(p) in ("agent", "assisted")))
362
+ from stackvitals.connectors.sonar import summary as sonar_summary
363
+ # The Sonar quality gate and measures over time, when [connectors.sonar] names a project.
364
+ out["code_quality"] = sonar_summary(ws.path, now)
365
+ out["excluded"] = flow.get("excluded", 0)
366
+ from stackvitals.core import delivery_peers
367
+ # Where it stands among similar public repos (the crawl's file; absent before launch or offline: no section).
368
+ out["peers"] = delivery_peers.compare(delivery_peers.own_metrics(flow["open"] + flow["closed"], now),
369
+ delivery_peers.load(None, ws.work / "bench"), # public source only, like Team Flow
370
+ info.get("language"), info.get("stars"))
371
+ from stackvitals.core.ai_outcomes import baseline
372
+ from stackvitals.flow import peers as flow_peers
373
+ # Coding agents' merge share in similar public repos (Team Flow loaded the crawl's file earlier in this job).
374
+ from stackvitals.core import agent_ledger
375
+ flow_bench = flow_peers.load(None, ws.work / "bench")
376
+ out["ai"]["baseline"] = baseline(flow_bench, info.get("language"), info.get("stars"))
377
+ # Each coding agent across the crawl's public repos, for this repo's own agents to be read against.
378
+ out["ai"]["leaderboard"] = agent_ledger.leaderboard((flow_bench or {}).get("agent_leaderboard"))
379
+ from stackvitals.core import capex
380
+ from stackvitals.core.investment import tracker_types
381
+ # For finance: every merged PR of the last year with its kind of work and epic (docs/capex.csv), and a summary.
382
+ epics = {}
383
+ if tracker:
384
+ from stackvitals.connectors.tracker import pr_links
385
+ by_key = {i.get("key"): i for i in tracker["issues"]}
386
+ epics = {n: (by_key.get(k) or {}).get("epic") or "" for n, k in pr_links(flow["closed"], tracker["issues"])}
387
+ cap_rows = capex.rows(flow["closed"], ws.repo, now, private=not public(ws), base=info.get("default_branch"),
388
+ rules=(cfg.get("investment") or {}).get("labels"),
389
+ tracker_types=tracker_types(flow["closed"], tracker["issues"]) if tracker else None, epics=epics)
390
+ out["capex"] = {"months": capex.summary(cap_rows)[-6:], "rows": len(cap_rows), "file": "capex.csv"}
391
+ (ws.path / "docs").mkdir(parents=True, exist_ok=True)
392
+ (ws.path / "docs" / "capex.csv").write_text(capex.to_csv(cap_rows), encoding="utf-8", newline="\n")
393
+ ws.cache["delivery"] = out # the Insights step after this one reads it
394
+ # Working-agreement nudges: each open pull request breaking one is announced once, decided before the commit.
395
+ from stackvitals.core.agreements import nudge_lines, nudges
396
+ fresh, keep = nudges(out.get("agreements"), store.state().get("nudged") or [])
397
+ if keep != (store.state().get("nudged") or []):
398
+ store.save_state({**store.state(), "nudged": keep})
399
+ from stackvitals.core.export import devlake_tables
400
+ from stackvitals.core.summary import write_devlake_summary
401
+ # CI runs of the last 90 days go into the data branch's DevLake tables, so the summary stays small on busy repos.
402
+ write_devlake_summary(ws.path, devlake_tables(ws.repo, flow, store.repo(), ci, issues, cfg, tracker=tracker,
403
+ ci_since=iso(now - timedelta(days=90))), now)
404
+ docs = ws.path / "docs"
405
+ docs.mkdir(parents=True, exist_ok=True)
406
+ write_if_changed(docs / "delivery.json", published(out),
407
+ also=lambda: (docs / "delivery.html").write_text(
408
+ render_html(out, ws.repo, shell.present(docs) | {"delivery.html"}), encoding="utf-8"))
409
+
410
+ def after_commit():
411
+ summary = env.get("GITHUB_STEP_SUMMARY")
412
+ failed = None
413
+ if fresh and env.get("STACKVITALS_WEBHOOK"):
414
+ from stackvitals.core import chat
415
+ lines = nudge_lines(fresh, out["agreements"]["labels"], ws.repo)
416
+ failed = chat.post(env["STACKVITALS_WEBHOOK"], chat.message(
417
+ f"Working agreements in {ws.repo}: {len(fresh)} pull request{'' if len(fresh) == 1 else 's'} to look at", lines,
418
+ [], {"repo": ws.repo, "nudges": [{"pr": e["pr"], "rule": e["rule"]} for e in fresh]}), what="the agreements webhook")
419
+ if summary:
420
+ with open(summary, "a", encoding="utf-8") as f:
421
+ f.write(render_markdown(out, ws.repo) + (f"\n{failed}" if failed else "") + "\n")
422
+ ws.after_commit(after_commit, "Delivery")
423
+ result = {"prs": out["prs"], "deployments": out["dora"]["deployments"], "generated": iso(now)}
424
+ if label_stuck:
425
+ if make_api is None:
426
+ from stackvitals.ci.api import ActionsAPI as make_api
427
+ result["labelled"] = label_stuck_prs(make_api(env.get("STACKVITALS_TOKEN"), ws.repo, max_requests=60), out,
428
+ flow.get("open") or [])
429
+ return result