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,224 @@
1
+ """Alerts on delivery numbers over the last 28 days: a heads-up at 80% of a threshold and a miss past it, each sent once
2
+ per change of state (and once on recovery), "would have fired N times" replayed over stored history, and payloads for
3
+ Slack, Discord, Microsoft Teams (Workflows) and plain JSON webhooks."""
4
+ from datetime import datetime, timedelta
5
+
6
+ import httpx
7
+
8
+ from stackvitals.core import chat
9
+ from stackvitals.core.stats import percentile, quantile_range, wilson
10
+ from stackvitals.core.time import iso
11
+
12
+ WINDOW_DAYS = 28
13
+ HEADS_UP = 0.8
14
+ MIN_N = 10
15
+ LEVELS = ("ok", "heads_up", "miss")
16
+ NAMES = {"pickup_p75_hours": "pickup time P75 (hours)", "review_p75_hours": "review time P75 (hours)",
17
+ "cycle_p75_hours": "cycle time to merge P75 (hours)", "change_failure_rate": "change failure rate"}
18
+ PHASE_OF = {"pickup_p75_hours": "pickup", "review_p75_hours": "review", "cycle_p75_hours": "cycle_to_merge"}
19
+
20
+
21
+ def thresholds(cfg: dict) -> dict:
22
+ """The thresholds in force: every metric set above 0."""
23
+ return {k: v for k, v in cfg["alerts"].items() if k in NAMES and v}
24
+
25
+
26
+ def series(rows: list[tuple], deps: list[dict], incidents: list[dict], now: datetime, days: int = 90,
27
+ window: int = WINDOW_DAYS) -> dict[str, list[tuple]]:
28
+ """Each metric for each of the last `days` days, over the `window` days before it: [(day, value, n, low)], oldest
29
+ first; `low` is the bottom of the value's likely range, so a miss can wait until the whole range is past the limit."""
30
+ out = {k: [] for k in NAMES}
31
+ failed = {i["deployment"] for i in incidents if i.get("deployment")}
32
+ for k in range(days, -1, -1):
33
+ end = now - timedelta(days=k)
34
+ lo, hi = iso(end - timedelta(days=window)), iso(end)
35
+ merged = [ph for p, ph, _dep in rows if p.get("mg") and lo < p["mg"] <= hi]
36
+ for metric, phase in PHASE_OF.items():
37
+ values = [ph[phase] for ph in merged if ph.get(phase) is not None]
38
+ rng = quantile_range(values, 0.75)
39
+ out[metric].append((end.date().isoformat(), percentile(values, 0.75) if values else None, len(values),
40
+ rng[0] if rng else None))
41
+ shipped = [d for d in deps if lo < d["finished"] <= hi]
42
+ bad = sum(1 for d in shipped if d["id"] in failed)
43
+ rate = bad / len(shipped) if shipped else None
44
+ out["change_failure_rate"].append((end.date().isoformat(), rate, len(shipped),
45
+ round(wilson(bad, len(shipped))[0], 4) if shipped else None))
46
+ return out
47
+
48
+
49
+ def _level(value: float | None, n: int, limit: float, low: float | None = None) -> str:
50
+ if value is None or n < MIN_N:
51
+ return "not enough data"
52
+ if value > limit:
53
+ # Over, but the likely range still reaches under the limit: a heads-up until the evidence settles it.
54
+ return "miss" if low is None or low > limit else "heads_up"
55
+ return "heads_up" if value >= HEADS_UP * limit else "ok"
56
+
57
+
58
+ def evaluate(values: dict[str, tuple], limits: dict, previous: dict) -> tuple[dict, list[dict]]:
59
+ """(new state, events) from today's (value, n) per metric: an event when a level rises, and "recovered" when a
60
+ metric that missed is back to ok."""
61
+ state, events = {}, []
62
+ for metric, limit in limits.items():
63
+ value, n, *rest = values.get(metric, (None, 0))
64
+ before = previous.get(metric) or {}
65
+ last = before.get("verdict") or "ok" # the last real verdict: a quiet week without data changes nothing
66
+ level = _level(value, n, limit, rest[0] if rest else None)
67
+ # An open miss stays a miss while the value stays over the limit, however its range moves.
68
+ if level == "heads_up" and before.get("open") and value is not None and value > limit:
69
+ level = "miss"
70
+ now = {"level": level, "verdict": last if level == "not enough data" else level, "open": before.get("open", False),
71
+ "value": value, "n": n}
72
+ if level in LEVELS[1:] and LEVELS.index(level) > LEVELS.index(last):
73
+ events.append({"metric": metric, "level": level, "value": value, "limit": limit, "n": n})
74
+ now["open"] = now["open"] or level == "miss"
75
+ elif level == "ok" and before.get("open"):
76
+ events.append({"metric": metric, "level": "recovered", "value": value, "limit": limit, "n": n})
77
+ now["open"] = False
78
+ state[metric] = now
79
+ return state, events
80
+
81
+
82
+ def replay(history: dict[str, list[tuple]], limits: dict) -> dict[str, int]:
83
+ """How many alerts each threshold would have sent over the stored days."""
84
+ fired = {m: 0 for m in limits}
85
+ state = {}
86
+ for k in range(max((len(v) for v in history.values()), default=0)):
87
+ today = {m: tuple(history[m][k][1:]) for m in limits if m in history and k < len(history[m])}
88
+ state, events = evaluate(today, limits, state)
89
+ for e in events:
90
+ fired[e["metric"]] += 1
91
+ return fired
92
+
93
+
94
+ def _fmt(metric: str, value) -> str:
95
+ return f"{value:.0%}" if metric == "change_failure_rate" else f"{value:.1f}"
96
+
97
+
98
+ def message(events: list[dict], repo: str, links=()) -> dict:
99
+ words = {"heads_up": "heads-up: near its limit", "miss": "over its limit", "recovered": "back under its limit"}
100
+ unsure = "heads-up: past its limit, but its likely range still reaches under it"
101
+ lines = [f"- **{NAMES[e['metric']]}** {unsure if e['level'] == 'heads_up' and e['value'] > e['limit'] else words[e['level']]}: "
102
+ f"{_fmt(e['metric'], e['value'])} against {_fmt(e['metric'], e['limit'])} (n={e['n']})." for e in events]
103
+ return chat.message(f"StackVitals alerts for {repo} (last {WINDOW_DAYS} days)", lines, links,
104
+ {"repo": repo, "alerts": events})
105
+
106
+
107
+ def send(url: str, events: list[dict], repo: str, client: httpx.Client | None = None, links=()) -> str | None:
108
+ """Post the alerts in the chat's own shape; trouble comes back as text for the job summary."""
109
+ return chat.post(url, message(events, repo, links), client, "the alert webhook")
110
+
111
+
112
+ STATE = "alerts/state.json"
113
+ ISSUE_TITLE = "StackVitals: delivery alerts"
114
+ ISSUE_LABEL = "stackvitals"
115
+
116
+
117
+ def _issue_body(state: dict, limits: dict) -> str:
118
+ words = {"ok": "ok", "heads_up": "near its limit", "miss": "over its limit", "not enough data": "not enough data"}
119
+ lines = [f"Delivery numbers over the last {WINDOW_DAYS} days against this repo's thresholds:", ""]
120
+ for metric, limit in limits.items():
121
+ s = state[metric]
122
+ value = "-" if s.get("value") is None else _fmt(metric, s["value"])
123
+ lines.append(f"- {NAMES[metric]}: {value} against {_fmt(metric, limit)} (n={s.get('n', 0)}): {words[s['level']]}")
124
+ lines += ["", "Change the thresholds under [alerts] in .github/stackvitals.toml (0 turns one off). This issue is kept "
125
+ "up to date and closed when every number is back under its limit."]
126
+ return "\n".join(lines)
127
+
128
+
129
+ def _sync_issue(api, state: dict, limits: dict) -> None:
130
+ found = [i for i in api._get(f"/repos/{api.repo}/issues", {"labels": ISSUE_LABEL, "state": "open", "per_page": 100}) or []
131
+ if i.get("title") == ISSUE_TITLE and "pull_request" not in i]
132
+ bad = any(state[m]["verdict"] in ("heads_up", "miss") for m in limits)
133
+ body = _issue_body(state, limits)
134
+ if found and not bad:
135
+ api.send("PATCH", f"/repos/{api.repo}/issues/{found[0]['number']}", {"body": body, "state": "closed",
136
+ "state_reason": "completed"})
137
+ elif found:
138
+ api.send("PATCH", f"/repos/{api.repo}/issues/{found[0]['number']}", {"body": body})
139
+ elif bad:
140
+ api.send("POST", f"/repos/{api.repo}/issues", {"title": ISSUE_TITLE, "body": body, "labels": [ISSUE_LABEL]})
141
+
142
+
143
+ def alerts_step(ws, env: dict, now: datetime, make_api=None, client: httpx.Client | None = None) -> dict | None:
144
+ """The Action's alerts after Team Flow (the schedule): today's numbers against the thresholds, sent once per change of
145
+ state; the stored state keeps levels only, so a run where nothing crossed a line commits nothing."""
146
+ import json
147
+
148
+ from stackvitals.ci.store import DataStore
149
+ from stackvitals.core.config import parse
150
+ from stackvitals.core.delivery import apply_identity, shipped
151
+ from stackvitals.flow.store import FlowStore
152
+ if ws.results.get("Team Flow") is None:
153
+ return None
154
+ cfg = ws.cache.get("settings") or parse("")[0]
155
+ limits = thresholds(cfg)
156
+ if not limits:
157
+ return None
158
+ store = FlowStore(ws.path)
159
+ from stackvitals.core.decisions import visible
160
+ flow = visible(ws.path, apply_identity(ws.cache.get("flow") or store.load(), cfg.get("identity")), now.date())
161
+ ci = ws.cache.get("ci") or (DataStore(ws.path).load() if (ws.path / "ci" / "days").is_dir() else {"runs": [], "jobs": []})
162
+ issues = []
163
+ if (ws.path / "community" / "issues").is_dir():
164
+ got = FlowStore(ws.path, "community/issues").load()
165
+ issues = got["open"] + got["closed"]
166
+ info = store.repo()
167
+ deps, _source, rows = shipped(flow, info, ci, cfg)
168
+ from stackvitals.connectors import active, public
169
+ from stackvitals.connectors import load as load_tracker
170
+ from stackvitals.core.delivery import incidents_for
171
+ tracker = ws.cache.get("tracker") or load_tracker(ws.path, redact=public(ws), names=active(env, cfg))
172
+ incidents = incidents_for(flow["closed"] + flow["open"], issues, deps, cfg, tracker, info.get("recorded"))[0]
173
+ history = series(rows, deps, incidents, now)
174
+ # Whole days only for the app's "would have fired" preview, so the published file changes at most once a day.
175
+ from stackvitals.core.store import published, write_if_changed
176
+ daily = series(rows, deps, incidents, now.replace(hour=0, minute=0, second=0, microsecond=0))
177
+ (ws.path / "docs").mkdir(parents=True, exist_ok=True)
178
+ write_if_changed(ws.path / "docs" / "alerts.json", published({
179
+ "window_days": WINDOW_DAYS, "heads_up": HEADS_UP, "min_n": MIN_N, "limits": limits, "names": NAMES,
180
+ "series": {m: [[d, None if v is None else round(v, 3), n, None if low is None else round(low, 3)]
181
+ for d, v, n, low in points] for m, points in daily.items()}}))
182
+ today = {m: tuple(history[m][-1][1:]) for m in limits}
183
+ path = ws.path / STATE
184
+ try:
185
+ old = json.loads(path.read_text(encoding="utf-8"))
186
+ except (OSError, ValueError):
187
+ old = {}
188
+ state, events = evaluate(today, limits, old.get("metrics") or {})
189
+ fired = replay(history, limits) if old.get("limits") != limits else None
190
+ notes, channels = [], cfg["alerts"]["channels"]
191
+ if events and "webhook" in channels and env.get("STACKVITALS_WEBHOOK"):
192
+ app = chat.app_url(ws.repo, ws.cache.get("repo_info"), "delivery")
193
+ links = [("Open Delivery", app)] if app else []
194
+ links.append(("Alerts issue", f"https://github.com/{ws.repo}/issues?q=is%3Aissue+label%3Astackvitals+alerts"))
195
+ failed = send(env["STACKVITALS_WEBHOOK"], events, ws.repo, client=client, links=links)
196
+ if failed:
197
+ notes.append(failed)
198
+ if (events or fired is not None) and "issue" in channels:
199
+ if make_api is None:
200
+ from stackvitals.ci.api import ActionsAPI as make_api
201
+ try:
202
+ _sync_issue(make_api(env.get("STACKVITALS_TOKEN"), ws.repo, max_requests=10), state, limits)
203
+ except Exception as exc: # noqa: BLE001 - a missing permission must not stop the job
204
+ notes.append(f"Could not update the alerts issue ({type(exc).__name__}); it needs `issues: write`.")
205
+ keep = {"limits": limits, "metrics": {m: {"verdict": s["verdict"], "open": s["open"]} for m, s in state.items()}}
206
+ if keep != old:
207
+ path.parent.mkdir(parents=True, exist_ok=True)
208
+ path.write_text(json.dumps(keep, indent=1, sort_keys=True), encoding="utf-8")
209
+
210
+ def after_commit():
211
+ summary = env.get("GITHUB_STEP_SUMMARY")
212
+ if not summary or not (events or fired is not None or notes):
213
+ return
214
+ lines = []
215
+ if events:
216
+ lines.append(chat.text(message(events, ws.repo)))
217
+ if fired is not None:
218
+ lines.append("With these thresholds, alerts would have fired " + ", ".join(
219
+ f"{n} times for {NAMES[m]}" for m, n in fired.items()) + f" over the last 90 days (n of each day's {WINDOW_DAYS}-day window shown with every alert).")
220
+ lines += notes
221
+ with open(summary, "a", encoding="utf-8") as f:
222
+ f.write("### StackVitals alerts\n" + "\n\n".join(lines) + "\n")
223
+ ws.after_commit(after_commit, "Alerts")
224
+ return {"events": len(events), "levels": {m: s["level"] for m, s in state.items()}, "would_have_fired": fired}
@@ -0,0 +1,112 @@
1
+ """The files the StackVitals app reads, written beside the pages at the end of every job: app.json (what ran and when,
2
+ links, the key facts), settings.json (every setting with its default and current value), decisions.json (what people
3
+ decided, with the log) and, for public repos, the summary tables, so the app and Explore read everything from Pages
4
+ itself rather than raw.githubusercontent.com, which GitHub throttles. Files are rewritten only when they change."""
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ from stackvitals.core import schema
10
+ from stackvitals.core.config import FILE, parse
11
+ from stackvitals.core.decisions import UNDO, Store
12
+ from stackvitals.core.store import published, write_if_changed
13
+
14
+ # Module, its published file and its page; Portal keeps its catalog outside docs/ (copied in for the app).
15
+ MODULES = (("ci", "data.json", "ci.html"), ("flow", "flow.json", "flow.html"), ("delivery", "delivery.json", "delivery.html"),
16
+ ("insights", "insights.json", "insights.html"), ("community", "community.json", "community.html"),
17
+ ("portal", "portal.json", "portal.html"), ("alerts", "alerts.json", None), ("deps", "deps.json", None),
18
+ ("supply", "supply.json", None), ("vulns", "vulns.json", None), ("security", "security.json", None))
19
+ LOG_ROWS = 200
20
+
21
+
22
+ def _read(path: Path):
23
+ try:
24
+ return json.loads(path.read_text(encoding="utf-8"))
25
+ except (OSError, ValueError):
26
+ return None
27
+
28
+
29
+ def facts(docs: dict) -> dict:
30
+ """The few numbers a home page, the digest or an org view lead with; None where a module didn't run."""
31
+ data, flow, delivery = docs.get("ci") or {}, docs.get("flow") or {}, docs.get("delivery") or {}
32
+ items = (data.get("tests") or []) + (data.get("jobs") or [])
33
+ phase = lambda name: ((delivery.get("phases") or {}).get(name) or {}).get("p75_h")
34
+ found = (docs.get("insights") or {}).get("findings") or []
35
+ return {
36
+ "ci_first_try": (data.get("pushes") or {}).get("first_try_share") if data else None,
37
+ "ci_flaky": sum(1 for i in items if i.get("state") == "flaky") if data else None,
38
+ "ci_broken": sum(1 for i in items if i.get("state") == "broken") if data else None,
39
+ "open_prs": flow.get("open"), "stale_prs": flow.get("stale"),
40
+ "pickup_p75_h": phase("pickup"), "review_p75_h": phase("review"), "cycle_p75_h": phase("cycle_to_merge"),
41
+ "deployments": (delivery.get("dora") or {}).get("deployments"),
42
+ "change_failure_rate": (delivery.get("dora") or {}).get("change_failure_rate"),
43
+ "newcomers_waiting": (docs.get("community") or {}).get("waiting_total"),
44
+ "findings": sum(1 for f in found if not f.get("decision")) if docs.get("insights") else None,
45
+ "first_review_median_s": ((flow.get("stages") or {}).get("pickup") or {}).get("median_s"),
46
+ "community_score": ((docs.get("community") or {}).get("score") or {}).get("score"),
47
+ "vulns_open": len((docs.get("vulns") or {}).get("open") or []) if docs.get("vulns") else None,
48
+ "vulns_serious": sum(1 for v in (docs.get("vulns") or {}).get("open") or []
49
+ if v.get("malware") or v.get("severity") in ("critical", "high")) if docs.get("vulns") else None,
50
+ }
51
+
52
+
53
+ def write_app_data(ws) -> None:
54
+ root, now = ws.path, ws.now
55
+ docs_dir = root / "docs"
56
+ if not docs_dir.is_dir():
57
+ return
58
+ info = ws.cache.get("repo_info") or {}
59
+ last = _read(docs_dir / "app.json") or {}
60
+ branch = info.get("default_branch") or last.get("default_branch") or "main"
61
+ # Without the repo's details, the last known visibility holds; never known means private (publish nothing).
62
+ private = bool(info["private"]) if "private" in info else last.get("visibility") != "public"
63
+ if private and info.get("has_pages") and (ws.env.get("STACKVITALS_PAGES_MODE") or "branch") != "artifact":
64
+ # Pages from a private repo are public on most plans: say so on every run until it's changed.
65
+ ws.notes.append("This repo is private but has GitHub Pages turned on; unless your plan keeps Pages private, anyone "
66
+ "with the address can read the app. Turn Pages off and set pages: artifact, or use stackvitals ui.")
67
+ catalog = _read(root / "portal" / "catalog.json")
68
+ if catalog is not None:
69
+ # The app's copy is packed (each rule named once) and compact: 10,000 repos would be 24 MB as written.
70
+ from stackvitals.portal.scorecard import pack
71
+ write_if_changed(docs_dir / "portal.json", published(pack(catalog)), compact=True)
72
+ docs = {name: _read(docs_dir / file) for name, file, _page in MODULES}
73
+ workflow = (ws.env.get("GITHUB_WORKFLOW_REF") or "").split("@")[0].rsplit("/", 1)[-1] or None
74
+ base = f"https://github.com/{ws.repo}"
75
+ state, log = Store(root).state(now.date()), Store(root).log()
76
+ write_if_changed(docs_dir / "app.json", published({
77
+ "repo": ws.repo, "default_branch": branch, "visibility": "private" if private else "public",
78
+ "modules": {name: {"updated": (docs[name] or {}).get("generated") or (docs[name] or {}).get("built_at"), "file": file,
79
+ **({"page": page} if page and (docs_dir / page).exists() else {})}
80
+ for name, file, page in MODULES if docs[name] is not None},
81
+ "pages": sorted(p.name for p in docs_dir.glob("*.html")),
82
+ "facts": {**facts(docs), "decisions": sum(1 for v in state.values() if v.get("kind") != "setting" and v.get("verb") not in UNDO)},
83
+ "links": {"repo": base, "workflow": workflow,
84
+ "actions": f"{base}/actions/workflows/{workflow}" if workflow else f"{base}/actions",
85
+ "settings_file": f"{base}/blob/{branch}/{FILE}", "edit_settings": f"{base}/edit/{branch}/{FILE}",
86
+ "new_issue": f"{base}/issues/new"},
87
+ "summary": "summary/" if not private else None,
88
+ }))
89
+ cfg = ws.cache.get("settings") or parse("")[0]
90
+ write_if_changed(docs_dir / "settings.json", published({
91
+ "file": FILE, "settings": schema.to_json(), "values": cfg, "notes": list(ws.cache.get("settings_notes") or [])}))
92
+ write_if_changed(docs_dir / "decisions.json", published({"state": state, "log": list(reversed(log))[:LOG_ROWS]}))
93
+ _tables(root, private)
94
+
95
+
96
+ def _tables(root: Path, private: bool) -> None:
97
+ """Public repos: the summary tables next to the pages (git keeps one copy of identical files). Private: kept out."""
98
+ src, dst = root / "summary", root / "docs" / "summary"
99
+ if private or not src.is_dir():
100
+ if dst.exists():
101
+ shutil.rmtree(dst)
102
+ return
103
+ dst.mkdir(parents=True, exist_ok=True)
104
+ names = {p.name for p in src.iterdir() if p.is_file()}
105
+ for p in dst.iterdir():
106
+ if p.is_file() and p.name not in names:
107
+ p.unlink()
108
+ for name in names:
109
+ data = (src / name).read_bytes()
110
+ target = dst / name
111
+ if not target.exists() or target.read_bytes() != data:
112
+ target.write_bytes(data)
@@ -0,0 +1,190 @@
1
+ """Optional storage outside git: the same files the stackvitals-data branch holds, kept in an S3-compatible bucket (AWS S3,
2
+ Cloudflare R2, MinIO and others that support conditional writes). For very busy repos and big orgs, where many runs
3
+ writing one branch collide. Files are stored by their content hash, and one manifest maps paths to hashes; the manifest
4
+ is replaced only if nobody replaced it since this run read it (If-Match), so overlapping runs converge as with git.
5
+ Requests are signed with AWS Signature V4; no SDK is needed."""
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import os
10
+ import random
11
+ import time
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from urllib.parse import quote, urlparse
15
+
16
+ MANIFEST = "manifest.json"
17
+ EMPTY = hashlib.sha256(b"").hexdigest()
18
+
19
+
20
+ class BucketError(RuntimeError):
21
+ pass
22
+
23
+
24
+ def _sha(data: bytes) -> str:
25
+ return hashlib.sha256(data).hexdigest()
26
+
27
+
28
+ def sign(method: str, url: str, headers: dict, payload_sha: str, key_id: str, secret: str, region: str,
29
+ now: datetime, service: str = "s3") -> dict:
30
+ """The headers of a request signed with AWS Signature Version 4 (path-style URL)."""
31
+ u = urlparse(url)
32
+ stamp, day = now.strftime("%Y%m%dT%H%M%SZ"), now.strftime("%Y%m%d")
33
+ all_headers = {**{k.lower(): str(v).strip() for k, v in headers.items()}, "host": u.netloc,
34
+ "x-amz-date": stamp, "x-amz-content-sha256": payload_sha}
35
+ names = sorted(all_headers)
36
+ query = "&".join(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}" for k, v in
37
+ sorted(tuple(p.split("=", 1)) if "=" in p else (p, "") for p in u.query.split("&") if p))
38
+ # The URL's path is already percent-encoded (BucketStore._url); S3 signs it as sent, never encoded twice.
39
+ canonical = "\n".join([method, u.path or "/", query,
40
+ "".join(f"{n}:{all_headers[n]}\n" for n in names), ";".join(names), payload_sha])
41
+ scope = f"{day}/{region}/{service}/aws4_request"
42
+ to_sign = "\n".join(["AWS4-HMAC-SHA256", stamp, scope, _sha(canonical.encode())])
43
+ key = f"AWS4{secret}".encode()
44
+ for part in (day, region, service, "aws4_request"):
45
+ key = hmac.new(key, part.encode(), hashlib.sha256).digest()
46
+ signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
47
+ return {**{k: v for k, v in all_headers.items() if k != "host"},
48
+ "authorization": f"AWS4-HMAC-SHA256 Credential={key_id}/{scope}, SignedHeaders={';'.join(names)}, Signature={signature}"}
49
+
50
+
51
+ class BucketStore:
52
+ """The data branch's interface (open, dirty, commit_push, squash) over a bucket prefix."""
53
+
54
+ def __init__(self, url: str, path: Path, endpoint: str, key_id: str, secret: str, region: str = "auto", client=None):
55
+ u = urlparse(url)
56
+ if u.scheme != "s3" or not u.netloc:
57
+ raise BucketError("storage must look like s3://bucket/prefix")
58
+ self.bucket, self.prefix = u.netloc, u.path.strip("/")
59
+ self.endpoint = endpoint.rstrip("/")
60
+ self.key_id, self.secret, self.region = key_id, secret, region or "auto"
61
+ self.path = Path(path)
62
+ self.client = client
63
+ self.etag = None
64
+ self.files: dict[str, str] = {}
65
+
66
+ # -- requests ------------------------------------------------------------------------------------------------
67
+ def _url(self, key: str) -> str:
68
+ return f"{self.endpoint}/{self.bucket}/{quote(key, safe='/-_.~')}"
69
+
70
+ def _key(self, name: str) -> str:
71
+ return f"{self.prefix}/{name}" if self.prefix else name
72
+
73
+ def _send(self, method: str, key: str, body: bytes = b"", headers: dict | None = None):
74
+ if self.client is None:
75
+ import httpx
76
+ self.client = httpx.Client(timeout=60)
77
+ url = self._url(key)
78
+ signed = sign(method, url, headers or {}, _sha(body) if body else EMPTY, self.key_id, self.secret, self.region,
79
+ datetime.now(timezone.utc))
80
+ for attempt in range(3):
81
+ try:
82
+ r = self.client.request(method, url, content=body or None, headers=signed)
83
+ except Exception as exc: # noqa: BLE001 - a dropped connection is retried, then said
84
+ if attempt == 2:
85
+ raise BucketError(f"{method} {key}: {type(exc).__name__}") from exc
86
+ time.sleep(1 + attempt)
87
+ continue
88
+ if r.status_code >= 500 and attempt < 2:
89
+ time.sleep(1 + attempt)
90
+ continue
91
+ return r
92
+ return r
93
+
94
+ # -- the data branch's interface ------------------------------------------------------------------------------
95
+ def open(self) -> None:
96
+ """Download every file the manifest lists into `path`, remembering the manifest's ETag."""
97
+ import shutil
98
+ for attempt in range(3):
99
+ if self.path.exists():
100
+ shutil.rmtree(self.path)
101
+ self.path.mkdir(parents=True)
102
+ r = self._send("GET", self._key(MANIFEST))
103
+ if r.status_code == 404:
104
+ self.etag, self.files = None, {}
105
+ return
106
+ if r.status_code == 403:
107
+ raise BucketError("reading the manifest: HTTP 403. The key must be able to read, write, delete and list "
108
+ "the prefix; AWS answers a missing file with 403 when listing isn't allowed")
109
+ if r.status_code != 200:
110
+ raise BucketError(f"reading the manifest: HTTP {r.status_code}")
111
+ self.etag = r.headers.get("etag")
112
+ self.files = json.loads(r.content).get("files") or {}
113
+ gone = False
114
+ for name, digest in self.files.items():
115
+ obj = self._send("GET", self._key(f"objects/{digest}"))
116
+ if obj.status_code == 404:
117
+ gone = True # another run swapped the manifest and cleaned up while this one read: start over
118
+ break
119
+ if obj.status_code != 200 or _sha(obj.content) != digest:
120
+ raise BucketError(f"reading {name}: HTTP {obj.status_code}")
121
+ target = self.path / name
122
+ target.parent.mkdir(parents=True, exist_ok=True)
123
+ target.write_bytes(obj.content)
124
+ if not gone:
125
+ return
126
+ time.sleep(backoff(attempt))
127
+ raise BucketError("the bucket kept changing while it was read; the next run will catch up")
128
+
129
+ def _current(self) -> dict[str, tuple[str, bytes]]:
130
+ out = {}
131
+ for p in self.path.rglob("*"):
132
+ if p.is_file():
133
+ data = p.read_bytes()
134
+ out[p.relative_to(self.path).as_posix()] = (_sha(data), data)
135
+ return out
136
+
137
+ def dirty(self) -> bool:
138
+ return {k: v[0] for k, v in self._current().items()} != self.files
139
+
140
+ def commit_push(self, message: str) -> str:
141
+ """Upload new contents, then swap the manifest only if nobody swapped it since open(): "pushed", "unchanged",
142
+ or "rejected" (the caller runs its steps again on the fresh data)."""
143
+ now = self._current()
144
+ files = {k: v[0] for k, v in now.items()}
145
+ if files == self.files:
146
+ return "unchanged"
147
+ for name, (digest, data) in now.items():
148
+ if self.files.get(name) != digest and digest not in set(self.files.values()):
149
+ r = self._send("PUT", self._key(f"objects/{digest}"), data)
150
+ if r.status_code not in (200, 201):
151
+ raise BucketError(f"writing {name}: HTTP {r.status_code}")
152
+ doc = json.dumps({"files": files, "message": message, "at": datetime.now(timezone.utc).isoformat()},
153
+ sort_keys=True).encode()
154
+ cond = {"if-match": self.etag} if self.etag else {"if-none-match": "*"}
155
+ r = self._send("PUT", self._key(MANIFEST), doc, cond)
156
+ if r.status_code in (409, 412):
157
+ # Another run won. What this attempt uploaded stays: objects are named by content, so a run still working
158
+ # may have uploaded the same file and be about to publish it. A few spare objects cost less than a lost one.
159
+ return "rejected"
160
+ if r.status_code not in (200, 201):
161
+ raise BucketError(f"writing the manifest: HTTP {r.status_code}")
162
+ old = set(self.files.values()) - set(files.values())
163
+ self.etag, self.files = r.headers.get("etag"), files
164
+ # Contents no file points to any more go; a run that read the old manifest only ever reads before this.
165
+ for digest in old:
166
+ self._send("DELETE", self._key(f"objects/{digest}"))
167
+ return "pushed"
168
+
169
+ def squash(self, message: str) -> str:
170
+ return "unchanged" # a bucket keeps no history to squash
171
+
172
+
173
+ def from_env(env: dict, path: Path, client=None) -> BucketStore | None:
174
+ """The bucket store when STACKVITALS_STORAGE names one (s3://bucket/prefix), else None (the git branch)."""
175
+ url = (env.get("STACKVITALS_STORAGE") or "").strip()
176
+ if not url or url == "git":
177
+ return None
178
+ missing = [k for k in ("STACKVITALS_S3_ENDPOINT", "STACKVITALS_S3_KEY_ID", "STACKVITALS_S3_SECRET") if not env.get(k)]
179
+ if missing:
180
+ raise BucketError(f"storage {url} needs {', '.join(missing)}")
181
+ return BucketStore(url, path, env["STACKVITALS_S3_ENDPOINT"], env["STACKVITALS_S3_KEY_ID"], env["STACKVITALS_S3_SECRET"],
182
+ env.get("STACKVITALS_S3_REGION") or "auto", client)
183
+
184
+
185
+ def backoff(attempt: int) -> float:
186
+ # Two runs that collided shouldn't retry in step: a random wait that grows with each attempt.
187
+ # Tests replay collisions on purpose (pytest names the running test in this variable) and skip the wait.
188
+ if os.environ.get("STACKVITALS_NO_BACKOFF") == "1" or os.environ.get("PYTEST_CURRENT_TEST"):
189
+ return 0
190
+ return random.uniform(1, 4) * (attempt + 1)
@@ -0,0 +1,43 @@
1
+ """The bug backlog week by week (DevLake's weekly bug retro): bugs opened and closed each week, how many stay open, and
2
+ how old the open ones are. From GitHub issues with a bug label alone, plus Jira and Linear bugs when connected."""
3
+ from datetime import datetime, timedelta
4
+
5
+ from stackvitals.connectors.base import std_type
6
+
7
+ WEEKS = 12
8
+ OLDEST = 10
9
+
10
+
11
+ def _t(s: str) -> datetime:
12
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
13
+
14
+
15
+ def _bugs(issues: list[dict], tracked: list[dict]) -> list[dict]:
16
+ out = [{"src": "github", "id": i["n"], "created": i["cr"], "closed": i.get("cl")}
17
+ for i in issues or [] if i.get("cr") and any(std_type(x) == "BUG" for x in i.get("lb") or [])]
18
+ out += [{"src": i.get("src"), "id": i.get("key") or i.get("id"), "created": i["created"],
19
+ "closed": i.get("resolved") if i.get("cat") == "DONE" else None, "url": i.get("url")}
20
+ for i in tracked or [] if i.get("type") == "BUG" and i.get("created") and not i.get("canceled")]
21
+ return out
22
+
23
+
24
+ def backlog(issues: list[dict], tracked: list[dict], now: datetime, weeks: int = WEEKS) -> dict | None:
25
+ """{"weeks": [{"week", "opened", "closed", "open"}], "open", "median_open_days", "oldest", "sources"}, or None."""
26
+ bugs = _bugs(issues, tracked)
27
+ if not bugs:
28
+ return None
29
+ monday = (now - timedelta(days=now.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
30
+ rows = []
31
+ for k in range(weeks - 1, -1, -1):
32
+ start, end = monday - timedelta(weeks=k), monday - timedelta(weeks=k - 1)
33
+ rows.append({"week": start.strftime("%Y-%m-%d"),
34
+ "opened": sum(start <= _t(b["created"]) < end for b in bugs),
35
+ "closed": sum(bool(b["closed"]) and start <= _t(b["closed"]) < end for b in bugs),
36
+ "open": sum(_t(b["created"]) < min(end, now) and (not b["closed"] or _t(b["closed"]) >= min(end, now))
37
+ for b in bugs)})
38
+ still = sorted((b for b in bugs if not b["closed"]), key=lambda b: b["created"])
39
+ ages = sorted((now - _t(b["created"])).days for b in still)
40
+ return {"weeks": rows, "open": len(still), "median_open_days": ages[len(ages) // 2] if ages else None,
41
+ "oldest": [{"src": b["src"], "id": b["id"], "days": (now - _t(b["created"])).days, **({"url": b["url"]} if b.get("url") else {})}
42
+ for b in still[:OLDEST]],
43
+ "sources": sorted({b["src"] for b in bugs})}
@@ -0,0 +1,34 @@
1
+ """A team's working calendar ([calendar] weekends and holidays): hours between two times with the non-working days
2
+ (whole UTC days) left out, so a review phase isn't charged a weekend nobody worked."""
3
+ from datetime import date, datetime, timedelta, timezone
4
+
5
+ DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
6
+
7
+
8
+ def _t(text: str) -> datetime:
9
+ return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc)
10
+
11
+
12
+ def is_set(cal: dict | None) -> bool:
13
+ return bool(cal and (cal.get("weekends") or cal.get("holidays")))
14
+
15
+
16
+ def elapsed_hours(a: str | None, b: str | None, cal: dict | None) -> float | None:
17
+ """Hours from a to b, without the weekends and holidays in `cal` (None: plain clock hours)."""
18
+ if not a or not b:
19
+ return None
20
+ start, end = _t(a), _t(b)
21
+ total = (end - start).total_seconds() / 3600
22
+ if not is_set(cal) or end <= start:
23
+ return round(total, 4)
24
+ off_days = {DAYS.index(d) for d in cal.get("weekends") or []}
25
+ holidays = {date.fromisoformat(h) for h in cal.get("holidays") or []}
26
+ day = start.date()
27
+ while day <= end.date():
28
+ if day.weekday() in off_days or day in holidays:
29
+ lo = max(start, datetime(day.year, day.month, day.day, tzinfo=timezone.utc))
30
+ hi = min(end, datetime(day.year, day.month, day.day, tzinfo=timezone.utc) + timedelta(days=1))
31
+ if hi > lo:
32
+ total -= (hi - lo).total_seconds() / 3600
33
+ day += timedelta(days=1)
34
+ return round(max(total, 0.0), 4)