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,57 @@
1
+ """Effort and capitalisation for finance: one row per merged pull request into the default branch (month, link, kind of
2
+ work and how it was decided, its epic or project when Jira or Linear is connected, written by a coding agent or a person)
3
+ as a CSV an auditor can follow back to GitHub, and a monthly summary of author-weeks by kind of work with the agent share.
4
+ Feature work is what teams usually capitalise; the rule stays the team's and its auditor's, these are the facts behind it."""
5
+ import csv
6
+ import io
7
+ from collections import defaultdict
8
+ from datetime import datetime, timedelta
9
+
10
+ from stackvitals.core.ai_outcomes import mode
11
+ from stackvitals.core.investment import classify
12
+
13
+ COLUMNS = ("month", "merged_at", "pr", "url", "title", "kind", "decided_by", "epic", "author", "author_kind")
14
+
15
+
16
+ def rows(prs: list[dict], repo: str, now: datetime, private: bool, base: str | None = None, months: int = 12,
17
+ rules: dict | None = None, tracker_types: dict | None = None, epics: dict | None = None) -> list[dict]:
18
+ """Merged PRs of the last `months` months, oldest first; a public repo's rows carry no titles."""
19
+ since = (now - timedelta(days=31 * months)).strftime("%Y-%m-%dT%H:%M:%SZ")
20
+ out = []
21
+ for p in sorted((p for p in prs if p.get("mg") and p["mg"] >= since and (not base or not p.get("base") or p["base"] == base)),
22
+ key=lambda p: p["mg"]):
23
+ kind, how = classify(p, rules, (tracker_types or {}).get(p["n"]))
24
+ row = {"month": p["mg"][:7], "merged_at": p["mg"], "pr": p["n"], "url": f"https://github.com/{repo}/pull/{p['n']}",
25
+ "kind": kind, "decided_by": how, "epic": (epics or {}).get(p["n"]) or "",
26
+ "author": "agent" if mode(p) == "agent" else "person", "author_kind": mode(p),
27
+ "_login": (p.get("au") or "").lower()} # for author-weeks only; never written to the file
28
+ if private:
29
+ row["title"] = (p.get("ti") or "")[:200]
30
+ out.append(row)
31
+ return out
32
+
33
+
34
+ def to_csv(rows: list[dict]) -> str:
35
+ buf = io.StringIO()
36
+ w = csv.DictWriter(buf, fieldnames=[c for c in COLUMNS if not rows or c in rows[0]], extrasaction="ignore",
37
+ lineterminator="\n")
38
+ w.writeheader()
39
+ w.writerows(rows)
40
+ return buf.getvalue()
41
+
42
+
43
+ def summary(rows: list[dict]) -> list[dict]:
44
+ """Per month: merged PRs, author-weeks by kind of work (one author's PRs of one kind in one ISO week count once),
45
+ the feature share and how many PRs coding agents wrote."""
46
+ months = defaultdict(lambda: {"prs": 0, "agent_prs": 0, "weeks": defaultdict(set), "kinds": defaultdict(int)})
47
+ for r in rows:
48
+ m = months[r["month"]]
49
+ m["prs"] += 1
50
+ m["agent_prs"] += r["author"] == "agent"
51
+ m["kinds"][r["kind"]] += 1
52
+ week = datetime.fromisoformat(r["merged_at"].replace("Z", "+00:00")).isocalendar()[:2]
53
+ m["weeks"][r["kind"]].add((r.get("_login"), week))
54
+ return [{"month": month, "prs": m["prs"], "agent_prs": m["agent_prs"], "kinds": dict(m["kinds"]),
55
+ "author_weeks": {k: len(v) for k, v in m["weeks"].items()},
56
+ "feature_share": round(m["kinds"].get("feature", 0) / m["prs"], 4) if m["prs"] else None}
57
+ for month, m in sorted(months.items())]
@@ -0,0 +1,181 @@
1
+ """Change risk before merge, from this repo's own history: which merged pull requests caused trouble (later reverted, or
2
+ shipped in a small deployment that then failed), how often changes to the same folders, of the same size, by someone
3
+ new to those folders, without test changes or by a coding agent did, and a score with its reasons for an open one.
4
+ A backtest on the newest pull requests says how much the score is worth here."""
5
+ import math
6
+ import re
7
+ from collections import defaultdict
8
+ from datetime import datetime, timedelta
9
+
10
+ from stackvitals.core.ai_outcomes import BATCH_MAX, _band, is_revert, mode, revert_target
11
+
12
+ MIN_TROUBLED = 5 # fewer troubled pull requests and there is no score, not a guess
13
+ MIN_PRS = 30
14
+ PRIOR = 5 # each factor's rate is pulled toward the repo's base rate as if it had 5 more ordinary PRs
15
+ NESTED = {"src", "lib", "libs", "packages", "apps", "services", "pkg", "internal", "app", "cmd"}
16
+ TESTS = ("test", "spec", "__tests__")
17
+
18
+
19
+ def troubled(rows: list, incidents: list[dict]) -> set[int]:
20
+ """Merged PRs a later revert undid, or that shipped in a small deployment (5 PRs or fewer) that failed."""
21
+ by_number = {p["n"]: p for p, _ph, _d in rows}
22
+ out = {n for p, _ph, _d in rows if is_revert(p) and (n := revert_target(p, by_number)) is not None}
23
+ batch = defaultdict(int)
24
+ for _p, _ph, d in rows:
25
+ if d:
26
+ batch[d["id"]] += 1
27
+ failed = {i.get("deployment") for i in incidents if i.get("deployment")}
28
+ out |= {p["n"] for p, _ph, d in rows if d and d["id"] in failed and batch[d["id"]] <= BATCH_MAX}
29
+ return out
30
+
31
+
32
+ FIX = re.compile(r"\b(fix|fixes|fixed|bug|bugfix|hotfix|regression)\b", re.I)
33
+ FOLLOW_UP_DAYS = 14
34
+ COMMON = 0.2 # a file most PRs touch (a lockfile, the changelog) links nothing
35
+
36
+
37
+ def _when(text: str) -> datetime:
38
+ return datetime.fromisoformat(text.replace("Z", "+00:00"))
39
+
40
+
41
+ def follow_up_fixes(prs: list[dict], days: int = FOLLOW_UP_DAYS) -> set[int]:
42
+ """Merged PRs that a fix (by its title or a label) changing the same files followed within `days`: the lightweight
43
+ form of SZZ, the standard way to find changes that needed fixing. Files most PRs touch are ignored."""
44
+ merged = sorted((p for p in prs if p.get("mg") and p.get("paths")), key=lambda p: p["mg"])
45
+ if not merged:
46
+ return set()
47
+ touched = defaultdict(int)
48
+ for p in merged:
49
+ for path in set(p["paths"]):
50
+ touched[path] += 1
51
+ common = {f for f, n in touched.items() if n > COMMON * len(merged)}
52
+ is_fix = lambda p: bool(FIX.search(p.get("ti") or "") or any(FIX.search(x) for x in p.get("lb") or [])) # noqa: E731
53
+ out = set()
54
+ for f in (p for p in merged if is_fix(p)):
55
+ files, end = set(f["paths"]) - common, _when(f["mg"])
56
+ if not files:
57
+ continue
58
+ for p in merged:
59
+ if p["n"] != f["n"] and not is_fix(p) and end - timedelta(days=days) <= _when(p["mg"]) < end \
60
+ and files & set(p["paths"]):
61
+ out.add(p["n"])
62
+ return out
63
+
64
+
65
+ def _dirs(p: dict) -> set[str]:
66
+ out = set()
67
+ for path in p.get("paths") or []:
68
+ parts = path.split("/")
69
+ if len(parts) < 2:
70
+ out.add("(root)")
71
+ else:
72
+ depth = 2 if parts[0] in NESTED and len(parts) > 2 else 1
73
+ out.add("/".join(parts[:depth]) + "/")
74
+ return out
75
+
76
+
77
+ def _tests(p: dict) -> bool:
78
+ return any(any(t in path.lower() for t in TESTS) for path in p.get("paths") or [])
79
+
80
+
81
+ def _facts(p: dict, seen: dict) -> dict:
82
+ dirs = _dirs(p)
83
+ who = (p.get("au") or "").lower()
84
+ return {"dirs": dirs, "band": _band(p), "new": bool(dirs) and not (dirs & seen.get(who, set())),
85
+ "tests": _tests(p), "agent": mode(p) == "agent"}
86
+
87
+
88
+ def fit(prs: list[dict], bad: set[int]) -> dict | None:
89
+ """Each factor's smoothed troubled rate, learnt from merged PRs oldest first (familiarity as it was at the time)."""
90
+ prs = sorted((p for p in prs if p.get("mg") and not is_revert(p)), key=lambda p: p["mg"])
91
+ if len(prs) < MIN_PRS or sum(1 for p in prs if p["n"] in bad) < MIN_TROUBLED:
92
+ return None
93
+ base = sum(1 for p in prs if p["n"] in bad) / len(prs)
94
+ counts = defaultdict(lambda: [0, 0]) # factor -> [PRs, troubled]
95
+ seen: dict[str, set] = defaultdict(set)
96
+ for p in prs:
97
+ f, hit = _facts(p, seen), p["n"] in bad
98
+ for key in [f"dir:{d}" for d in f["dirs"]] + [f"band:{f['band']}", f"new:{f['new']}", f"tests:{f['tests']}",
99
+ f"agent:{f['agent']}"]:
100
+ counts[key][0] += 1
101
+ counts[key][1] += hit
102
+ seen[(p.get("au") or "").lower()] |= f["dirs"]
103
+ rates = {k: (t + PRIOR * base) / (n + PRIOR) for k, (n, t) in counts.items()}
104
+ return {"base": base, "rates": rates, "counts": {k: v for k, v in counts.items()}, "seen": seen, "prs": len(prs)}
105
+
106
+
107
+ def _odds(p: float) -> float:
108
+ p = min(max(p, 1e-4), 1 - 1e-4)
109
+ return p / (1 - p)
110
+
111
+
112
+ def score(p: dict, model: dict | None) -> dict | None:
113
+ """{"risk", "level", "reasons"} for one PR; None without a model."""
114
+ if not model:
115
+ return None
116
+ base, rates, counts = model["base"], model["rates"], model["counts"]
117
+ f = _facts(p, model["seen"])
118
+ dir_keys = [f"dir:{d}" for d in f["dirs"] if f"dir:{d}" in rates]
119
+ # The riskiest folder speaks for the change; other factors are combined as odds against the base rate.
120
+ keys = ([max(dir_keys, key=lambda k: rates[k])] if dir_keys else []) + \
121
+ [k for k in (f"band:{f['band']}", f"new:{f['new']}", f"tests:{f['tests']}", f"agent:{f['agent']}") if k in rates]
122
+ log_odds = math.log(_odds(base)) + sum(math.log(_odds(rates[k]) / _odds(base)) for k in keys)
123
+ risk = 1 / (1 + math.exp(-log_odds))
124
+ reasons = []
125
+ for k in keys:
126
+ if rates[k] < 1.5 * base:
127
+ continue
128
+ n, t = counts[k]
129
+ kind, value = k.split(":", 1)
130
+ reasons.append({"dir": f"{value}: {t} of {n} changes here caused trouble",
131
+ "band": f"a change of about {(p.get('add') or 0) + (p.get('del') or 0):,} lines ({t} of {n} this size did)",
132
+ "new": "the author's first change in these folders",
133
+ "tests": "no test files changed" if value == "False" else "test files changed",
134
+ "agent": "written by a coding agent"}[kind])
135
+ level = "high" if risk >= 2 * base and risk >= 0.1 else "medium" if risk >= 1.3 * base else "low"
136
+ return {"risk": round(risk, 4), "level": level, "base": round(base, 4), "reasons": reasons}
137
+
138
+
139
+ def _auc(scored: list[tuple[float, bool]]) -> float | None:
140
+ pos = [s for s, y in scored if y]
141
+ neg = [s for s, y in scored if not y]
142
+ if not pos or not neg:
143
+ return None
144
+ wins = sum((a > b) + 0.5 * (a == b) for a in pos for b in neg)
145
+ return wins / (len(pos) * len(neg))
146
+
147
+
148
+ def backtest(prs: list[dict], bad: set[int], share: float = 0.7) -> dict | None:
149
+ """Learn from the oldest 70% of merged PRs and score the newest 30%: how well the score ranks the troubled ones
150
+ (AUC, 0.5 = no better than chance) and the trouble rate among the top fifth scored."""
151
+ merged = sorted((p for p in prs if p.get("mg") and not is_revert(p)), key=lambda p: p["mg"])
152
+ cut = int(len(merged) * share)
153
+ train, test = merged[:cut], merged[cut:]
154
+ model = fit(train, bad)
155
+ hits = [p for p in test if p["n"] in bad]
156
+ if not model or len(hits) < 3:
157
+ return None
158
+ scored = sorted(((score(p, model)["risk"], p["n"] in bad) for p in test), key=lambda x: -x[0])
159
+ top = scored[:max(1, len(scored) // 5)]
160
+ return {"train": len(train), "test": len(test), "test_troubled": len(hits), "auc": round(_auc(scored), 3),
161
+ "top_fifth_trouble": round(sum(y for _s, y in top) / len(top), 3),
162
+ "overall_trouble": round(len(hits) / len(test), 3)}
163
+
164
+
165
+ MIN_AUC = 0.65 # below this, this repo's own backtest says the score isn't worth showing
166
+
167
+
168
+ def assess(prs: list[dict], bad: set[int], open_prs: list[dict], top: int = 50) -> dict:
169
+ """The backtest first; open PRs scored (riskiest first) only when it shows the score ranks trouble here."""
170
+ bt = backtest(prs, bad)
171
+ if not bt:
172
+ return {"backtest": None, "open": None, "troubled": len(bad),
173
+ "note": "Not enough history yet: it needs about 30 merged pull requests and a few that needed a fix."}
174
+ if bt["auc"] < MIN_AUC:
175
+ return {"backtest": bt, "open": None, "troubled": len(bad),
176
+ "note": f"Not predictive in this repo: on its newest pull requests it ranked trouble about as well as chance "
177
+ f"(AUC {bt['auc']:.2f}), so no score is shown."}
178
+ model = fit(prs, bad)
179
+ scored = [{"pr": p.get("n"), **score(p, model)} for p in open_prs]
180
+ scored.sort(key=lambda s: -s["risk"])
181
+ return {"backtest": bt, "open": scored[:top], "troubled": len(bad), "note": None}
@@ -0,0 +1,133 @@
1
+ """Week against baseline: the latest seven days of a number against the four weeks before them, with n on both sides
2
+ and a p-value, so the digest flags only the changes that survive false-alarm control across everything it checked."""
3
+ import math
4
+ from datetime import datetime, timedelta
5
+
6
+ from stackvitals.core import stats
7
+ from stackvitals.core.time import iso
8
+
9
+ WEEK, BASE_WEEKS = timedelta(days=7), 4
10
+ HISTORY_WEEKS = 8 # past weeks that show how much this repo's weeks usually swing
11
+ # The typical swing before a repo's own history is known, worth 4 weeks: review times swing 1.5 to 2 times what
12
+ # independent PRs would (20 public repos; 1.5 chosen on half of them, checked on the other half); shares and counts 1.
13
+ PRIOR_SPREAD, PRIOR_WEEKS = {"share": 1.0, "count": 1.0}, 4
14
+ TIME_SPREAD = 1.5
15
+ # One wild past week (a backlog merged at once) counts as at most a four-sigma swing: the same catches as three sigma
16
+ # on 29 public repos, with fewer false flags (7.6% and 8.8% of shuffled scans on each half against 7.9% and 9.7%).
17
+ Z2_CAP = 16.0
18
+ MIN_EACH = 5 # fewer on either side and a shift says nothing
19
+ Q = 0.1 # the share of flagged changes allowed to be false alarms, across one scan
20
+
21
+
22
+ def windows(now: datetime, weeks: int = 1) -> tuple[tuple[str, str], tuple[str, str]]:
23
+ """(the last `weeks` weeks, the four weeks before them) as ISO bounds, start included and end left out."""
24
+ cut = now - WEEK * weeks
25
+ return (iso(cut), iso(now)), (iso(cut - WEEK * BASE_WEEKS), iso(cut))
26
+
27
+
28
+ def _row(metric: str, label: str, now, base, n_now: int, n_base: int, p: float, better: int, unit: str,
29
+ note: str | None = None, **extra) -> dict:
30
+ direction = None if now is None or base is None else "same" if now == base else "up" if now > base else "down"
31
+ worse = direction in ("up", "down") and better != 0 and (direction == "up") == (better < 0)
32
+ return {"metric": metric, "label": label, "now": now, "base": base, "n_now": n_now, "n_base": n_base,
33
+ "p": round(p, 6), "direction": direction, "better": better, "worse": worse, "unit": unit, "note": note,
34
+ **extra}
35
+
36
+
37
+ def share(metric: str, label: str, k_now: int, n_now: int, k_base: int, n_base: int, better: int) -> dict:
38
+ """A share this week against the weeks before (Fisher's exact test)."""
39
+ enough = min(n_now, n_base) >= MIN_EACH
40
+ p = stats.fisher(k_now, n_now, k_base, n_base) if enough else 1.0
41
+ value = lambda k, n: round(k / n, 4) if n else None # noqa: E731
42
+ return _row(metric, label, value(k_now, n_now), value(k_base, n_base), n_now, n_base, p, better, "share",
43
+ None if enough else "too few to tell")
44
+
45
+
46
+ def times(metric: str, label: str, now_values: list[float], base_values: list[float], better: int, unit: str,
47
+ span: int = 1) -> dict:
48
+ """A duration over the last `span` weeks against the four before (medians shown, Mann-Whitney tested)."""
49
+ a, b = [v for v in now_values if v is not None], [v for v in base_values if v is not None]
50
+ enough = min(len(a), len(b)) >= MIN_EACH
51
+ p = stats.mann_whitney(a, b) if enough else 1.0
52
+ mid = lambda v: None if not v else round(stats.median(v), 2) # noqa: E731
53
+ logs = [math.log(max(v, 1 / 60)) for v in b] # a minute's floor: instant merges would make the log endless
54
+ log_sd = round(stats.stdev(logs), 4) if len(logs) > 1 else None
55
+ return _row(metric, label, mid(a), mid(b), len(a), len(b), p, better, unit, None if enough else "too few to tell",
56
+ log_sd=log_sd, span=span)
57
+
58
+
59
+ def count(metric: str, label: str, now_count: int, base_count: int, better: int) -> dict:
60
+ """A weekly count against the weekly average of the weeks before (binomial on the combined count)."""
61
+ enough = now_count + base_count >= MIN_EACH
62
+ p = stats.count_change(now_count, base_count, BASE_WEEKS) if enough else 1.0
63
+ return _row(metric, label, now_count, round(base_count / BASE_WEEKS, 2), now_count, base_count, p, better, "count",
64
+ None if enough else "too few to tell")
65
+
66
+
67
+ def calibrate(checks_at, now: datetime, weeks: int = HISTORY_WEEKS) -> list[dict]:
68
+ """This week's checks with each p-value judged against the same check in the repo's past weeks.
69
+
70
+ Work arrives in batches (a busy review day, a release week), so events in one week are not independent and a
71
+ plain test overstates certainty. Each check is also run for each past week against its own four before it; how
72
+ widely those results swing (their mean squared z, blended with a typical swing worth four weeks) scales this
73
+ week's z, read on a t-distribution with one degree of freedom per week of evidence (an empirical null).
74
+ A check over several weeks is judged against past windows that don't overlap it, so an ongoing slowdown doesn't
75
+ count as the repo's usual swing."""
76
+ current = checks_at(now)
77
+ longest = max((c.get("span", 1) for c in current), default=1)
78
+ past = {j: checks_at(now - WEEK * j) for j in range(1, weeks + longest)}
79
+ out = []
80
+ for i, c in enumerate(current):
81
+ span = c.get("span", 1)
82
+ z2 = [min(stats.normal_isf(week[i]["p"]) ** 2, Z2_CAP) for week in (past[j] for j in range(span, span + weeks))
83
+ if i < len(week) and week[i]["note"] is None]
84
+ if c["note"] is None:
85
+ prior = PRIOR_SPREAD.get(c["unit"], TIME_SPREAD)
86
+ spread = max(1.0, (sum(z2) + PRIOR_WEEKS * prior) / (len(z2) + PRIOR_WEEKS))
87
+ p = stats.t_two_sided(stats.normal_isf(c["p"]) / math.sqrt(spread), len(z2) + PRIOR_WEEKS)
88
+ c = {**c, "p": round(p, 6), "p_raw": c["p"], "spread": round(spread, 2), "weeks_seen": len(z2),
89
+ "df": len(z2) + PRIOR_WEEKS, "scale": _scale(c, spread)}
90
+ out.append(c)
91
+ return out
92
+
93
+
94
+ def _scale(c: dict, spread: float) -> float | None:
95
+ # One standard error of the change on its own scale (log time, or share), widened by the repo's usual swing.
96
+ if c["unit"] == "count" or not c["n_now"] or not c["n_base"]:
97
+ return None
98
+ both = math.sqrt(1 / c["n_now"] + 1 / c["n_base"]) * math.sqrt(spread)
99
+ if c["unit"] == "share":
100
+ pb = min(max(c["base"] or 0, 0.02), 0.98)
101
+ return round(math.sqrt(pb * (1 - pb)) * both, 5)
102
+ if not c.get("log_sd"):
103
+ return None
104
+ return round(c["log_sd"] * both * 1.023, 5) # rank tests on skewed times lose about 2% against the ideal test
105
+
106
+
107
+ def detectable(c: dict, run: int, q: float = Q) -> float | None:
108
+ """The change this check would have caught with about 80% chance in a scan of `run` checks: a factor for times,
109
+ points of share for shares, None for counts."""
110
+ if c.get("scale") is None:
111
+ return None
112
+ z = stats.t_isf(q / max(run, 1), c["df"]) + 0.8416
113
+ return round(math.exp(z * c["scale"]) if c["unit"] not in ("share", "count") else z * c["scale"], 4)
114
+
115
+
116
+ def flag(found: list[dict], q: float = Q) -> list[dict]:
117
+ """Every change with `changed` set: True only for those surviving Benjamini-Hochberg over the checks actually run
118
+ in the scan (one with too few items to tell is not a test, so it doesn't count against the others)."""
119
+ run = [i for i, c in enumerate(found) if c.get("note") is None]
120
+ keep = {i for i, k in zip(run, stats.bh([found[i]["p"] for i in run], q)) if k}
121
+ # A pairing from an earlier pass (a module's own) is decided again here, over this scan's checks.
122
+ out = [{**{k: v for k, v in c.items() if k != "shown_with"}, "changed": i in keep and c["direction"] in ("up", "down")}
123
+ for i, c in enumerate(found)]
124
+ # A slowdown flagged both this week and over 14 days is one finding: the 14-day row is shown with the weekly one.
125
+ weekly = {c["metric"]: c for c in out if c["changed"]}
126
+ return [{**c, "shown_with": c["metric"][:-4]} if c["metric"].endswith("_14d") and c["changed"]
127
+ and weekly.get(c["metric"][:-4], {}).get("direction") == c["direction"] else c for c in out]
128
+
129
+
130
+ def annotate(found: list[dict], q: float = Q) -> list[dict]:
131
+ """A module's checks as its page shows them: flagged within the module, each with the change it could have caught."""
132
+ run = sum(1 for c in found if c.get("note") is None)
133
+ return [{**c, "detectable": detectable(c, run, q) if c.get("note") is None else None} for c in flag(found, q)]
@@ -0,0 +1,143 @@
1
+ """Chat messages in each chat's own shape: Slack Block Kit with link buttons (and Slack's own markdown), a Teams Adaptive
2
+ Card with open-link actions (Teams Workflows), a Discord embed, or plain JSON for anything else; and which webhook a
3
+ team's message goes to. Buttons only open links: nothing here needs a server to answer clicks."""
4
+ import re
5
+
6
+ import httpx
7
+
8
+ BOLD = re.compile(r"\*\*(.+?)\*\*")
9
+ LINK = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)")
10
+ TEAM_LINE = re.compile(r"^\s*(@?[\w.-]+(?:/[\w.-]+)?)\s*=\s*(https://\S+)\s*$")
11
+ SLACK_SECTION, SLACK_BLOCKS, SLACK_BUTTONS, DISCORD_TEXT = 3000, 50, 5, 4096
12
+
13
+
14
+ def message(title: str, lines: list[str], links=(), data: dict | None = None) -> dict:
15
+ """What every chat gets: a title, Markdown lines (**bold**, `code`, [text](url)), up to five link buttons, and the
16
+ numbers for plain JSON webhooks."""
17
+ return {"title": title, "lines": list(lines), "links": list(links), "data": data or {}}
18
+
19
+
20
+ def safe(name, size: int = 200) -> str:
21
+ """A name from the repo (a test, a job, a package) as inert text: one line that can't become a link (no chat pings plain @names)."""
22
+ out = " ".join(str(name).split())[:size].replace("`", "'")
23
+ return out.replace("](", "]\u200b(").replace("://", ":\u200b//")
24
+
25
+
26
+ def kind(url: str) -> str:
27
+ if "hooks.slack.com" in url:
28
+ return "slack"
29
+ if "discord.com/api/webhooks" in url or "discordapp.com/api/webhooks" in url:
30
+ return "discord"
31
+ if "logic.azure.com" in url or "webhook.office.com" in url or "powerplatform.com" in url:
32
+ return "teams"
33
+ return "json"
34
+
35
+
36
+ def text(msg: dict) -> str:
37
+ """The message as plain lines (job summaries, fallbacks)."""
38
+ return "\n".join([msg["title"], *msg["lines"]])
39
+
40
+
41
+ def _slack_escape(text: str) -> str:
42
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
43
+
44
+
45
+ def _slack_md(text: str) -> str:
46
+ text = _slack_escape(text)
47
+ return LINK.sub(r"<\2|\1>", BOLD.sub(r"*\1*", text))
48
+
49
+
50
+ def _chunks(lines: list[str], size: int) -> list[str]:
51
+ out, cur = [], ""
52
+ for line in lines:
53
+ line = line[:size]
54
+ if cur and len(cur) + 1 + len(line) > size:
55
+ out.append(cur)
56
+ cur = line
57
+ else:
58
+ cur = f"{cur}\n{line}" if cur else line
59
+ return out + ([cur] if cur else [])
60
+
61
+
62
+ def _slack(msg: dict) -> dict:
63
+ blocks = [{"type": "header", "text": {"type": "plain_text", "text": msg["title"][:150]}}]
64
+ sections = _chunks([_slack_md(x) for x in msg["lines"]], SLACK_SECTION)
65
+ room = SLACK_BLOCKS - 1 - (1 if msg["links"] else 0)
66
+ blocks += [{"type": "section", "text": {"type": "mrkdwn", "text": s}} for s in sections[:room]]
67
+ if msg["links"]:
68
+ blocks.append({"type": "actions", "elements": [
69
+ {"type": "button", "text": {"type": "plain_text", "text": label[:75]}, "url": url}
70
+ for label, url in msg["links"][:SLACK_BUTTONS]]})
71
+ # The fallback is read as Slack markup too (notifications), so it is escaped like the sections.
72
+ return {"text": _slack_escape(text(msg))[:3000], "blocks": blocks}
73
+
74
+
75
+ def _teams(msg: dict) -> dict:
76
+ card = {"type": "AdaptiveCard", "version": "1.4", "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
77
+ "body": [{"type": "TextBlock", "text": msg["title"], "weight": "Bolder", "size": "Medium", "wrap": True},
78
+ *({"type": "TextBlock", "text": line, "wrap": True} for line in msg["lines"])],
79
+ "actions": [{"type": "Action.OpenUrl", "title": label, "url": url} for label, url in msg["links"][:SLACK_BUTTONS]]}
80
+ return {"type": "message", "attachments": [{"contentType": "application/vnd.microsoft.card.adaptive", "content": card}]}
81
+
82
+
83
+ def _discord(msg: dict) -> dict:
84
+ links = " · ".join(f"[{label}]({url})" for label, url in msg["links"])
85
+ body = "\n".join(msg["lines"])
86
+ room = DISCORD_TEXT - len(links) - 2
87
+ text = (body if len(body) <= room else body[:room - 1] + "…") + (f"\n\n{links}" if links else "")
88
+ return {"embeds": [{"title": msg["title"][:256], "description": text[:DISCORD_TEXT],
89
+ **({"url": msg["links"][0][1]} if msg["links"] else {})}], "allowed_mentions": {"parse": []}}
90
+
91
+
92
+ def payload(url: str, msg: dict) -> dict:
93
+ shape = kind(url)
94
+ if shape == "slack":
95
+ return _slack(msg)
96
+ if shape == "teams":
97
+ return _teams(msg)
98
+ if shape == "discord":
99
+ return _discord(msg)
100
+ return {"source": "stackvitals", "title": msg["title"], "text": text(msg),
101
+ "links": [{"label": label, "url": url} for label, url in msg["links"]], **msg["data"]}
102
+
103
+
104
+ def post(url: str, msg: dict, client: httpx.Client | None = None, what: str = "the webhook") -> str | None:
105
+ """Post a message in the chat's own shape; a failure comes back as text for the job summary, never as an exception."""
106
+ url = url.strip() # a secret pasted with a trailing newline
107
+ try:
108
+ response = (client or httpx.Client(timeout=30)).post(url, json=payload(url, msg))
109
+ except (httpx.HTTPError, httpx.InvalidURL) as exc:
110
+ return f"Could not send {what} ({type(exc).__name__})."
111
+ if response.status_code >= 300:
112
+ return f"{what[0].upper()}{what[1:]} answered {response.status_code}."
113
+ return None
114
+
115
+
116
+ def app_url(repo: str, info: dict | None, route: str = "") -> str | None:
117
+ """The monitor's address on GitHub Pages, when the repo is public and publishes Pages; else None."""
118
+ if not info or info.get("private") or not info.get("has_pages"):
119
+ return None
120
+ owner, name = repo.split("/")
121
+ base = f"https://{owner.lower()}.github.io/" if name.lower() == f"{owner.lower()}.github.io" else f"https://{owner.lower()}.github.io/{name}/"
122
+ return base + (f"#/{route}" if route else "")
123
+
124
+
125
+ def team_hooks(env: dict) -> dict[str, str]:
126
+ """`STACKVITALS_TEAM_WEBHOOKS`, one `owner = https://...` per line (owners as CODEOWNERS names them, @org/team)."""
127
+ out = {}
128
+ for line in (env.get("STACKVITALS_TEAM_WEBHOOKS") or "").splitlines():
129
+ found = TEAM_LINE.match(line)
130
+ if found:
131
+ out[found.group(1)] = found.group(2)
132
+ return out
133
+
134
+
135
+ def route(owners_by_item: dict[str, list[str]], env: dict) -> dict[str, list[str]]:
136
+ """Which webhook gets which items: each owning team with its own webhook gets its items, the rest go to the default."""
137
+ teams, default = team_hooks(env), (env.get("STACKVITALS_WEBHOOK") or "").strip()
138
+ out = {}
139
+ for item, owners in owners_by_item.items():
140
+ hooks = [teams[o] for o in owners or [] if o in teams]
141
+ for hook in dict.fromkeys(hooks or ([default] if default else [])):
142
+ out.setdefault(hook, []).append(item)
143
+ return out