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,205 @@
1
+ """Every setting in `.github/stackvitals.toml` in one list: what it does, its type and limits, and where it shows. Defaults
2
+ come from core/config.py, so this list, the parser, docs/settings.md and the app's settings editor always agree."""
3
+ from dataclasses import dataclass
4
+
5
+ from stackvitals.core import config
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Setting:
10
+ key: str # dotted path in the file, e.g. "alerts.pickup_p75_hours"
11
+ kind: str # int, number, share, hours, bool, switch, regex, text, date, choice, choices, list, map, teams, dates,
12
+ # services
13
+ help: str
14
+ module: str # the app section it belongs to
15
+ choices: tuple = ()
16
+ unit: str = ""
17
+
18
+
19
+ _M = "ci.monitors"
20
+ SETTINGS = (
21
+ Setting(f"{_M}.pass_on_retry.min_events", "int", "Fail-then-pass events with no code change before a test or job is flaky.", "ci"),
22
+ Setting(f"{_M}.pass_on_retry.quiet_days", "int", "Days without a flake before the pass-on-retry monitor lets go.", "ci", unit="days"),
23
+ Setting(f"{_M}.pass_on_retry.quiet_runs", "int", "Clean runs in a row before a flaky test counts as fixed.", "ci", unit="runs"),
24
+ Setting(f"{_M}.failure_rate.enabled", "bool", "Watch each test's failure share on the default branch.", "ci"),
25
+ Setting(f"{_M}.failure_rate.window_runs", "int", "Latest default-branch runs the failure rate looks at.", "ci", unit="runs"),
26
+ Setting(f"{_M}.failure_rate.min_runs", "int", "Runs needed before the failure-rate monitor can fire.", "ci", unit="runs"),
27
+ Setting(f"{_M}.failure_rate.activate", "share", "Failure share that turns the monitor on.", "ci"),
28
+ Setting(f"{_M}.failure_rate.resolve", "share", "The monitor stays on while the failure share is above this.", "ci"),
29
+ Setting(f"{_M}.broken.streak", "int", "Default-branch runs in a row that all failed: the test keeps failing (broken).", "ci", unit="runs"),
30
+ Setting(f"{_M}.broken.window_runs", "int", "Latest runs the broken monitor looks at.", "ci", unit="runs"),
31
+ Setting(f"{_M}.broken.activate", "share", "Failure share in that window that marks a test broken.", "ci"),
32
+ Setting(f"{_M}.broken.resolve", "share", "A broken test stays broken while its failure share is at least this.", "ci"),
33
+ Setting(f"{_M}.slow.p50_ms", "int", "Label a test slow when its median passing time is above this.", "ci", unit="ms"),
34
+ Setting(f"{_M}.timeout_inflation.ratio", "number", "Label a test when its median failure time is at least this many times its P95 pass time.", "ci", unit="x"),
35
+ Setting(f"{_M}.timeout_inflation.min_failures", "int", "Failures needed before that label shows.", "ci"),
36
+ Setting("ci.mute", "dates", "Test ids or glob patterns muted until a date: still measured, but no alert and no new issue.", "ci"),
37
+ Setting("ci.quarantine.always", "list", "Test ids always on the quarantine list, flaky or not.", "ci"),
38
+ Setting("ci.quarantine.never", "list", "Test ids never quarantined, even when flaky.", "ci"),
39
+ Setting("ci.quarantine.max_days", "int", "Days a flaky test may stay in quarantine on its own before it fails builds again and is listed as overdue for a fix; 0 never ends it. Tests someone quarantined by hand stay.", "ci"),
40
+ Setting("ci.coverage.min_patch", "share", "The verdict check fails when fewer of a pull request's added lines ran than this (from the coverage report CI uploads); 0 is off.", "ci"),
41
+ Setting("ci.triage.provider", "choice", "A model reads the log of up to 3 failures the history can't decide and says whether they look like the change, beside the verdict (never in it); needs the triage-key input. Setups for each provider: docs/triage.md (gemini has a free tier).", "ci", choices=("off", "gemini", "openai", "anthropic", "mistral", "groq")),
42
+ Setting("ci.triage.model", "text", "The model to ask (empty: the provider's suggested small model).", "ci"),
43
+ Setting("ci.triage.url", "text", "Another OpenAI-compatible endpoint (https) instead of the provider's own.", "ci"),
44
+ Setting("ci.triage.allow_private", "bool", "Send a private repo's failing logs too (off: free tiers may keep what they're sent).", "ci"),
45
+ Setting("delivery.deploy_workflow", "regex", "Workflow names that count as deployments when the repo has no production deployments.", "delivery"),
46
+ Setting("delivery.production", "list", "GitHub deployment environments that count as production (matched on the first word).", "delivery"),
47
+ Setting("delivery.incident_labels", "list", "Issue labels that mark an incident.", "delivery"),
48
+ Setting("delivery.hotfix_labels", "list", "Pull request labels that mark a hotfix (counted as an incident).", "delivery"),
49
+ Setting("delivery.source", "choice", "Where deployments come from; auto tries deployments, deploy workflows, then releases; merges counts every merged pull request as one (only when chosen: it is a poor guess for most repos).", "delivery",
50
+ choices=config.SOURCES),
51
+ Setting("delivery.fix_patterns", "list", "Regular expressions on a merged pull request's title or branch that mark it as a fix, with one group naming the pull request it fixes, like ^fix.*#([0-9]+) or ^hotfix/([0-9]+)-; the incident blames the deployment that shipped that pull request.", "delivery"),
52
+ Setting("delivery.incident_sources", "list", "Where incidents come from: any of issues, reverts, hotfixes, fixes, pager, tracker, recorded; empty keeps the automatic choice (a pager tool's alone when connected, else all of GitHub's).", "delivery"),
53
+ Setting("delivery.production_branches", "list", "Branches besides the default one that ship to production (globs, like production or release/*): deploy workflows on them count, and so do pull requests merged into them.", "delivery"),
54
+ Setting("delivery.ignore_reverts_of", "list", "Reverts that are not incidents: of pull requests touching only these paths (globs, like docs/**) or by these authors (like dependabot[bot]).", "delivery"),
55
+ Setting("delivery.dora_report", "choice", "The DORA report whose tiers DevLake's dashboard uses.", "delivery", choices=("2023", "2021")),
56
+ Setting("investment.labels", "teams", "Your own labels for each kind of work (feature, fix, maintenance, security, docs), "
57
+ "like maintenance = [\"area: platform\"] under [investment.labels]; checked before the common ones, for where "
58
+ "the work went.", "delivery"),
59
+ Setting("rules", "rules", "PR rules as code: each [[rules]] has a name, a when (author, paths, lines_over, risk, labels, "
60
+ "first_time) and a then (label, reviewers, comment); dry_run = true only reports. Each action happens once per "
61
+ "pull request; the Pull Requests page shows what each rule matched before and now.", "flow"),
62
+ Setting("calendar.weekends", "list", "Days the team doesn't work, like saturday and sunday; left out of review phases and cycle "
63
+ "time (DORA's DevLake-matching numbers stay in calendar time, with a working-time lead time beside them).", "delivery"),
64
+ Setting("calendar.holidays", "list", "Dates the team doesn't work, like 2026-12-25; left out the same way.", "delivery"),
65
+ Setting("services", "services", "Services inside this repo (a monorepo): under [services.<name>], paths is a list of "
66
+ "CODEOWNERS-style patterns (like [\"services/api/\", \"libs/shared/**\"]) and deploy an optional pattern matched "
67
+ "against each deployment's environment or workflow name. Delivery then shows review phases per service, and "
68
+ "DORA per service for those with a deploy pattern; a pull request touching two services counts in both.", "delivery"),
69
+ Setting("identity.bots", "list", "Logins that are bots even if GitHub says otherwise.", "people"),
70
+ Setting("identity.people", "list", "Logins that are people even if they look like bots.", "people"),
71
+ Setting("identity.aliases", "map", "Old login to current login, so one person's history stays together. A key can also "
72
+ "be a tool's id (`jira:<account id>`, `linear:<user id>`, `slack:<member id>`, `discord:<user id>`) or an email, "
73
+ "so one person counts once across tools.", "people"),
74
+ Setting("identity.teams", "teams", "Team name to its members' logins, for numbers by team.", "people"),
75
+ Setting("identity.knowledge_names", "bool", "On a public repo, name the person behind each area in the knowledge map (folders one person knows, authors gone quiet). Off by default: public pages show the areas only. Private repos always show names.", "people"),
76
+ Setting("alerts.pickup_p75_hours", "hours", "Alert when 3 in 4 PRs wait longer than this for a first comment or review (last 28 days); 0 turns it off.", "alerts", unit="h"),
77
+ Setting("alerts.review_p75_hours", "hours", "Alert when 3 in 4 reviews take longer than this; 0 turns it off.", "alerts", unit="h"),
78
+ Setting("alerts.cycle_p75_hours", "hours", "Alert when 3 in 4 PRs take longer than this from first commit to merge; 0 turns it off.", "alerts", unit="h"),
79
+ Setting("alerts.change_failure_rate", "share", "Alert when more than this share of deployments led to an incident; 0 turns it off.", "alerts"),
80
+ Setting("alerts.channels", "choices", "Where alerts go: the webhook from the workflow, and/or one issue in this repo.", "alerts",
81
+ choices=config.CHANNELS),
82
+ Setting("automation.pr_comments", "switch", "One comment on failed PR runs saying which failures are known flakes.", "automation"),
83
+ Setting("automation.rerun_flaky", "switch", "Re-run failed jobs once when every failure is a known flake (needs actions: write).", "automation"),
84
+ Setting("automation.quarantine", "switch", "Keep the quarantine list for the stackvitals ci quarantine command.", "automation"),
85
+ Setting("automation.issues", "switch", "One issue per flaky test or job, closed once fixed (needs issues: write).", "automation"),
86
+ Setting("automation.mention_owners", "switch", "@-mention code owners in those issues (off: owners are named without a notification).", "automation"),
87
+ Setting("automation.assign_owners", "switch", "Assign a new flaky-test issue to its first code owner who is a person.", "automation"),
88
+ Setting("automation.preflight_comments", "switch", "One note on new outside PRs that miss what merged PRs here usually include.", "automation"),
89
+ Setting("automation.deps_comments", "switch", "One comment on same-repo PRs listing changed packages and their verdicts.", "automation"),
90
+ Setting("automation.review_requests", "switch", "Off until turned on: request the top suggested reviewer (who knows the folders, who reviewed the author's PRs, eased by load) on ready PRs nobody was asked to review; once per PR. Needs pull-requests: write.", "automation"),
91
+ Setting("automation.fix_requests", "switch", "Off until turned on: one issue per failure that breaks the default branch every "
92
+ "run, closed with proof once fixed. Needs issues: write.", "automation"),
93
+ Setting("automation.fix_by_copilot", "switch", "With fix_requests: assign each new one to Copilot's coding agent.", "automation"),
94
+ Setting("automation.size_labels", "switch", "Off until turned on: a size/XS to size/XL label on each open PR by lines changed (under 10, 100, 500, 1000).", "automation"),
95
+ Setting("automation.docs_label", "switch", "Off until turned on: label PRs that only change docs (Markdown, docs/, README) as low risk: docs only; never approves.", "automation"),
96
+ Setting("automation.label_stuck", "switch", "Label PRs whose asked-for review has not started after 3 days, and unlabel them later.", "automation"),
97
+ Setting("automation.verdict_check", "switch", "A \"StackVitals verdict\" check on pull requests: one required check that waits for every workflow on the newest commit and passes once CI is green (and patch coverage holds); known flakes are re-run, never let through (with rerun_flaky, automatically). Needs checks: write. On a private repo also set the repository variable STACKVITALS_EVERY_CI_RUN to true, so passing CI runs start a job.", "automation"),
98
+ Setting("automation.verdict_strict", "switch", "With the verdict check: when a failure the comment called likely from this change goes green on a re-run of the same code, the check stays red until someone with triage access comments /stackvitals accept rerun on the pull request. Off by default (a green re-run passes).", "automation"),
99
+ Setting("agreements.review_within_hours", "hours", "Agreement: every PR gets a first comment or review within this many hours; 0 is off.", "agreements", unit="h"),
100
+ Setting("agreements.merge_within_days", "number", "Agreement: PRs merge within this many days of being ready; 0 is off.", "agreements", unit="days"),
101
+ Setting("agreements.max_pr_lines", "int", "Agreement: PRs stay under this many changed lines; 0 is off.", "agreements", unit="lines"),
102
+ Setting("agreements.linked_issue", "bool", "Agreement: every PR links the issue it solves.", "agreements"),
103
+ Setting("agreements.review_before_merge", "bool", "Agreement: nothing merges without a review.", "agreements"),
104
+ Setting("digest.enabled", "bool", "Post a weekly digest: what changed, what to fix first, decisions ending soon.", "digest"),
105
+ Setting("digest.pulse", "bool", "With the issue digest, three pulse questions (flow, friction, satisfaction) answered by reaction; next week's digest shows only the counts (GitHub shows reactions to anyone who can see the issue; on a public repo only the team's own people count).", "digest"),
106
+ Setting("digest.day", "choice", "The day the digest goes out (the first scheduled run after the hour).", "digest", choices=config.DAYS),
107
+ Setting("digest.hour", "int", "The hour, in UTC, from which the digest may go out on that day.", "digest", unit="UTC"),
108
+ Setting("digest.channels", "choices", "Where the digest goes: one pinned issue in this repo, and/or the webhook.", "digest",
109
+ choices=config.CHANNELS),
110
+ Setting("deps.policy.min_scorecard", "number", "Projects behind the dependencies scoring under this OpenSSF Scorecard (0 to 10) are listed on the supply page, unless a decision accepted the package. Empty is off.", "deps"),
111
+ Setting("deps.policy.max_severity", "text", "The pull request check flags a changed package with an open advisory rated above this (low, moderate, high or critical). Empty is off.", "deps"),
112
+ Setting("deps.outside_lookups", "bool", "Look dependencies' public package names up at deps.dev, OpenSSF Scorecard, OSV, npm/PyPI release data, endoflife.date and the registries' download counts (docs/privacy.md); off scans with downloaded OSV databases, skips release checks, runtimes and GuardDog, and keeps every dependency name in the repo.", "deps"),
113
+ Setting("deps.guarddog", "bool", "Off by default: the pull request check also runs DataDog's GuardDog on each npm or PyPI package version it adds or upgrades (at most 10), which downloads the package and looks for malicious code (install hooks, obfuscation, data sent out); a finding is a reason to look first. Needs outside_lookups on; Linux runners only.", "deps"),
114
+ Setting("deps.licenses.deny", "list", "SPDX licence ids the repo's dependencies must not use (* allowed, like GPL-3.0*); a package offering an allowed choice is fine. Empty is off.", "deps"),
115
+ Setting("benchmarks.share", "bool", "Off by default: each CI run's summary offers a link that files this repo's rounded CI figures (pushes, time to green, first-try share; no names, no code) as an issue on StackVitals' public repo, so private repos get peer groups once 10+ of a kind have shared. Nothing is sent unless a person opens the link and files it.", "ci"),
116
+ Setting("benchmarks.name", "bool", "With benchmarks.share on: include this repo's name in the shared figures. Off shares them without it.", "ci"),
117
+ Setting("community.capacity", "text", "Who maintains the project and how much time they have, in your words (like \"two volunteers, evenings and weekends\"); shown on the Community page so the numbers are read against it.", "community"),
118
+ Setting("community.norms", "text", "How the project works with people, in your words (like \"issues are triaged on Mondays; ideas go to Discussions first\"); shown on the Community page.", "community"),
119
+ Setting("connectors.jira.url", "text", "Your Jira Cloud site, like https://acme.atlassian.net. The API token goes in the JIRA_API_TOKEN secret, never here.", "integrations"),
120
+ Setting("connectors.jira.email", "text", "The Jira account's email, when it is not in the JIRA_EMAIL secret.", "integrations"),
121
+ Setting("connectors.jira.scope_jql", "text", "JQL for the issues to read, like project in (WEB, API); empty reads every issue the account can see.", "integrations"),
122
+ Setting("connectors.jira.incident_jql", "text", "JQL for the issues that are incidents, like issuetype = Incident; they count in DORA's change failure rate and recovery.", "integrations"),
123
+ Setting("connectors.jira.key_expires", "date", "When the Jira API token expires (a year at most); a warning shows 14 days before.", "integrations"),
124
+ Setting("connectors.jira.write_back", "bool", "Comment on the Jira issue a merged pull request names (the account must be able to comment); off by default.", "integrations"),
125
+ Setting("connectors.jira.done_transition", "text", "With write-back on, the transition to move that issue through, like Done; empty only comments.", "integrations"),
126
+ Setting("connectors.jira.flaky_tickets", "bool", "Open a Jira issue for each test that turns flaky, in ticket_project, and comment and move it to done_transition (or Done) once fixed; the key must be able to write.", "integrations"),
127
+ Setting("connectors.jira.ticket_project", "text", "The Jira project key flaky-test issues go in, like WEB.", "integrations"),
128
+ Setting("connectors.jira.team_field", "text", "A Jira field that names each issue's team (its name, like Team, or its id, like customfield_10001): done issues are counted per team. Private repos only.", "integrations"),
129
+ Setting("connectors.jira.severity_field", "text", "A Jira field that gives each incident's severity (name or id), shown with the incident.", "integrations"),
130
+ Setting("connectors.linear.teams", "list", "Linear team keys to read, like ENG; empty reads every team the key can see.", "integrations"),
131
+ Setting("connectors.linear.incident_labels", "list", "Linear labels that mark an incident.", "integrations"),
132
+ Setting("connectors.linear.key_expires", "date", "When the Linear key expires, if it does; a warning shows 14 days before.", "integrations"),
133
+ Setting("connectors.linear.write_back", "bool", "Comment on the Linear issue a merged pull request names (the key needs write access); off by default.", "integrations"),
134
+ Setting("connectors.linear.done_state", "text", "With write-back on, the team's workflow state to move that issue to, like Done; empty only comments.", "integrations"),
135
+ Setting("connectors.linear.flaky_tickets", "bool", "Open a Linear issue for each test that turns flaky, in ticket_team, and comment and move it to done_state (or Done) once fixed; the key must be able to write.", "integrations"),
136
+ Setting("connectors.linear.ticket_team", "text", "The Linear team key flaky-test issues go in, like ENG.", "integrations"),
137
+ Setting("connectors.discourse.url", "text", "The project's Discourse forum, like https://discuss.example.org; its public topics are read with no key (times and flags only, never text or usernames).", "integrations"),
138
+ Setting("connectors.discourse.team", "list", "Forum usernames whose replies count as the project's (forum staff always do).", "integrations"),
139
+ Setting("connectors.discourse.groups", "list", "Forum groups whose members' replies count as the project's.", "integrations"),
140
+ Setting("connectors.slack.channels", "list", "Slack support channel ids to read (the bot from the slack-token input must be in each); only times and flags are kept.", "integrations"),
141
+ Setting("connectors.slack.team", "list", "Slack user ids whose replies count as the project's.", "integrations"),
142
+ Setting("connectors.slack.key_expires", "date", "When the Slack bot token expires, if it does; a warning shows 14 days before.", "integrations"),
143
+ Setting("connectors.discord.channels", "list", "Discord channel ids to read (the bot from the discord-token input needs View Channel and Read Message History); only times and flags are kept.", "integrations"),
144
+ Setting("connectors.discord.team", "list", "Discord user ids whose replies count as the project's.", "integrations"),
145
+ Setting("connectors.discord.key_expires", "date", "When the Discord bot token expires, if it does; a warning shows 14 days before.", "integrations"),
146
+ Setting("connectors.pagerduty.services", "list", "PagerDuty service ids whose incidents count; empty counts every service the key can see.", "integrations"),
147
+ Setting("connectors.pagerduty.urgencies", "choices", "Which PagerDuty urgencies count as production incidents.", "integrations",
148
+ choices=config.URGENCIES),
149
+ Setting("connectors.pagerduty.key_expires", "date", "When the PagerDuty key expires, if it does; a warning shows 14 days before.", "integrations"),
150
+ Setting("connectors.incident_io.incident_types", "list", "incident.io incident types that count, like Platform; empty counts every type (test and tutorial incidents never count).", "integrations"),
151
+ Setting("connectors.incident_io.key_expires", "date", "When the incident.io key expires, if it does; a warning shows 14 days before.", "integrations"),
152
+ Setting("connectors.copilot.org", "text", "The organization whose Copilot usage reports to keep; empty means this repo's owner.", "integrations"),
153
+ Setting("connectors.copilot.key_expires", "date", "When the Copilot reports token expires, if it does; a warning shows 14 days before.", "integrations"),
154
+ Setting("connectors.sonar.url", "text", "SonarCloud (https://sonarcloud.io) or your SonarQube server.", "integrations"),
155
+ Setting("connectors.sonar.project", "text", "The Sonar project key, like myorg_myrepo; its quality gate and measures are read each day. A private project or SonarQube server needs the SONAR_TOKEN secret.", "integrations"),
156
+ Setting("connectors.sonar.key_expires", "date", "When the Sonar token expires, if one is used; a warning shows 14 days before.", "integrations"),
157
+ Setting("connectors.ai_spend.key_expires", "date", "When the soonest of the AI spend admin keys (Anthropic, OpenAI, "
158
+ "Cursor) expires, if it does; a warning shows 14 days before.", "integrations"),
159
+ )
160
+ BY_KEY = {s.key: s for s in SETTINGS}
161
+
162
+
163
+ def default(key: str):
164
+ node = config.DEFAULTS
165
+ for part in key.split("."):
166
+ node = node[part]
167
+ return node
168
+
169
+
170
+ def to_json() -> list[dict]:
171
+ """The list for the app's settings editor."""
172
+ return [{"key": s.key, "kind": s.kind, "default": default(s.key), "help": s.help, "module": s.module,
173
+ **({"choices": list(s.choices)} if s.choices else {}), **({"unit": s.unit} if s.unit else {})} for s in SETTINGS]
174
+
175
+
176
+ def _shown(value) -> str:
177
+ if value is None:
178
+ return "the workflow input"
179
+ if isinstance(value, bool):
180
+ return "true" if value else "false"
181
+ if isinstance(value, (list, tuple)):
182
+ return "[" + ", ".join(f'"{v}"' for v in value) + "]" if value else "[] (none)"
183
+ if isinstance(value, dict):
184
+ return "{} (none)" if not value else str(value)
185
+ return f'"{value}"' if isinstance(value, str) else str(value)
186
+
187
+
188
+ def markdown() -> str:
189
+ """docs/settings.md: every setting with its default, grouped by section."""
190
+ lines = ["# StackVitals settings", "",
191
+ f"Put these in `{config.FILE}` on your default branch. A merged change applies on the next run (the workflow",
192
+ "runs when the file is pushed). A missing file means the defaults; a value StackVitals can't use keeps its",
193
+ "default and is named in the job summary. The app's Settings page edits the same file.", ""]
194
+ section = None
195
+ for s in SETTINGS:
196
+ head = s.key.rsplit(".", 1)[0] if s.kind not in ("dates",) else s.key
197
+ if head != section:
198
+ section = head
199
+ lines += ["", f"## [{section}]", "", "| Key | Default | What it does |", "|---|---|---|"]
200
+ name = s.key.rsplit(".", 1)[-1] if s.kind != "dates" else '"test id or pattern"'
201
+ unit = f" {s.unit}" if s.unit and s.kind not in ("share",) else ""
202
+ lines.append(f"| `{name}` | `{_shown(default(s.key))}`{unit} | {s.help} |")
203
+ lines += ["", "Example:", "", "```toml", "[alerts]", "pickup_p75_hours = 6", "channels = [\"webhook\", \"issue\"]", "",
204
+ "[ci.mute]", "\"tests/test_api.py::test_slow_network\" = 2026-12-31", "", "[automation]", "issues = true", "```", ""]
205
+ return "\n".join(lines)
@@ -0,0 +1,203 @@
1
+ """One reader per capability, shared by the MCP server, the CLIs and the Action, so every surface answers from the same
2
+ window, with the same budget and the same evidence."""
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from stackvitals.ci import bench
8
+ from stackvitals.ci.action import BRANCH, add_owners
9
+ from stackvitals.ci.analyze import analyze
10
+ from stackvitals.ci.api import APIError, ActionsAPI
11
+ from stackvitals.ci.collect import TEST_ARTIFACTS, collect
12
+ from stackvitals.ci.store import DataStore
13
+ from stackvitals.ci.triggers import pr_edit_workflows, pr_metadata_workflows
14
+ from stackvitals.core.store import accept
15
+
16
+ DEFAULT_BUDGET = 1500
17
+ FRESH_S = 3600
18
+
19
+
20
+ def _fresh(state: dict, now: datetime) -> bool:
21
+ last = state.get("last_collect")
22
+ return bool(last and state.get("default_branch")
23
+ and (now - datetime.fromisoformat(last.replace("Z", "+00:00"))).total_seconds() < FRESH_S)
24
+
25
+
26
+ def ci_result(repo: str, days: int, tok: str | None, home: Path, make_api=ActionsAPI, now: datetime | None = None,
27
+ budget: int = DEFAULT_BUDGET, tests: bool = True, window: int | None = None, log=None,
28
+ artifact_pattern: str = TEST_ARTIFACTS, owners: bool = False) -> tuple[dict, str]:
29
+ """CI Health's findings for a repo and where they came from: the repo's own published file (one request) when it
30
+ runs StackVitals; else a local copy read within the hour; else a live read of the last `days` days, test reports
31
+ included, with the peer benchmark attached. `result["collect"]` says whether the live read was complete."""
32
+ now = now or datetime.now(timezone.utc)
33
+ window = window or days
34
+ api = make_api(tok, repo, max_requests=budget)
35
+ try:
36
+ found = accept(json.loads(api.file("docs/data.json", BRANCH)))
37
+ except (APIError, ValueError):
38
+ found = None
39
+ if found is not None:
40
+ return found, "stackvitals-data branch"
41
+ store = DataStore(Path(home) / "ci" / repo.replace("/", "__"))
42
+ state = store.state()
43
+ if _fresh(state, now):
44
+ info, source = {"default_branch": state["default_branch"], "language": state.get("language"),
45
+ "stargazers_count": state.get("stars")}, "local copy read within the hour"
46
+ collected, note = {"complete": True, "stopped": None}, ""
47
+ else:
48
+ info = api._get(f"/repos/{repo}")
49
+ stats = collect(api, store, now, days=days, tests=tests, artifact_pattern=artifact_pattern, log=log)
50
+ store.save_state({**store.state(), "default_branch": info.get("default_branch"), "language": info.get("language"),
51
+ "stars": info.get("stargazers_count")})
52
+ source = f"read live from GitHub, last {days} days"
53
+ note = "" if stats["complete"] else ", partly (GitHub's hourly limit; ask again later for more)"
54
+ collected = {"complete": stats["complete"],
55
+ "stopped": None if stats["complete"] else store.state().get("last_stop") or "stopped early"}
56
+ data = store.load()
57
+ today = now.date().isoformat()
58
+ meta = pr_metadata_workflows(api, store, data, info.get("default_branch"), today)
59
+ result = analyze(data, now, window, default_branch=info.get("default_branch"), pr_metadata=meta,
60
+ pr_edits=pr_edit_workflows(store))
61
+ result["benchmark"] = bench.compare(result["pushes"], bench.load(tok, Path(home) / "bench", client=api.client),
62
+ info.get("language"), info.get("stargazers_count"))
63
+ if owners:
64
+ add_owners(api, store, result, info.get("default_branch"), today)
65
+ result["collect"] = collected
66
+ return result, source + note
67
+
68
+
69
+ def local_stores(repo: str, days: int, tok: str | None, home: Path, make_api=ActionsAPI, make_gql=None,
70
+ now: datetime | None = None, budget: int = 3000, log=None) -> tuple[dict, dict, dict]:
71
+ """This machine's copies of a repo's CI runs and pull requests, refreshed unless read within the hour:
72
+ (CI data, pull requests, CI state)."""
73
+ from stackvitals.flow.collect import collect as flow_collect
74
+ from stackvitals.flow.store import FlowStore
75
+ now = now or datetime.now(timezone.utc)
76
+ api = make_api(tok, repo, max_requests=budget)
77
+ store = DataStore(Path(home) / "ci" / repo.replace("/", "__"))
78
+ if not _fresh(store.state(), now):
79
+ info = api._get(f"/repos/{repo}")
80
+ collect(api, store, now, days=days, tests=False, log=log)
81
+ store.save_state({**store.state(), "default_branch": info.get("default_branch"), "language": info.get("language"),
82
+ "stars": info.get("stargazers_count")})
83
+ data = store.load()
84
+ # Which checks read the PR's own text, kept in the state (each workflow file is read at most once a week).
85
+ pr_metadata_workflows(api, store, data, store.state().get("default_branch"), now.date().isoformat())
86
+ flow = FlowStore(Path(home) / "flow" / repo.replace("/", "__"))
87
+ last = flow.state().get("last_collect")
88
+ if not (last and (now - datetime.fromisoformat(last.replace("Z", "+00:00"))).total_seconds() < FRESH_S):
89
+ if make_gql is None:
90
+ from stackvitals.flow.cli import local_gql as make_gql
91
+ owner, name = repo.split("/", 1)
92
+ flow_collect(make_gql(tok), flow, owner, name, now, months=max(2, days // 30 + 1))
93
+ return data, flow.load(), store.state()
94
+
95
+
96
+ def red_ci(repo: str, days: int, tok: str | None, home: Path, make_api=ActionsAPI, make_gql=None,
97
+ now: datetime | None = None, log=None) -> tuple[dict, str]:
98
+ """How long merged PRs sat with failing checks: the repo's published insight when it runs StackVitals, else computed
99
+ from this machine's copies of its CI runs and pull requests."""
100
+ from stackvitals.core import insights
101
+ now = now or datetime.now(timezone.utc)
102
+ api = make_api(tok, repo, max_requests=20)
103
+ try:
104
+ found = accept(json.loads(api.file("docs/insights.json", BRANCH)))
105
+ except (APIError, ValueError):
106
+ found = None
107
+ if found is not None and found.get("red_ci"):
108
+ return found["red_ci"], "stackvitals-data branch"
109
+ ci, flow, state = local_stores(repo, days, tok, home, make_api, make_gql, now, log=log)
110
+ out = insights.red_ci(flow["open"] + flow["closed"], ci, now, window_days=days, state=state)
111
+ return out, f"CI runs and pull requests read from GitHub, last {days} days"
112
+
113
+
114
+ def delivery(repo: str, days: int, tok: str | None, home: Path, make_api=ActionsAPI, make_gql=None,
115
+ now: datetime | None = None, log=None) -> tuple[dict, str]:
116
+ """Delivery for a repo: its published docs/delivery.json when it runs StackVitals, else computed from this machine's
117
+ copies of its pull requests, CI runs and (when kept) issues, with the repo's own settings file."""
118
+ from stackvitals.core.delivery import build
119
+ now = now or datetime.now(timezone.utc)
120
+ api = make_api(tok, repo, max_requests=20)
121
+ try:
122
+ found = accept(json.loads(api.file("docs/delivery.json", BRANCH)))
123
+ except (APIError, ValueError):
124
+ found = None
125
+ if found is not None and found.get("phases"):
126
+ return found, "stackvitals-data branch"
127
+ flow, info, ci, issues, cfg = delivery_inputs(repo, days, tok, home, make_api=make_api, make_gql=make_gql, now=now, log=log)
128
+ out = build(flow, info, ci, issues, now, days, cfg)
129
+ from stackvitals.core import delivery_peers
130
+ out["peers"] = delivery_peers.compare(delivery_peers.own_metrics(flow["open"] + flow["closed"], now),
131
+ delivery_peers.load(tok, Path(home) / "bench"), info.get("language"), info.get("stars"))
132
+ from stackvitals.core.ai_outcomes import baseline
133
+ from stackvitals.flow import peers as flow_peers
134
+ out["ai"]["baseline"] = baseline(flow_peers.load(tok, Path(home) / "bench"), info.get("language"), info.get("stars"))
135
+ return out, f"pull requests and CI runs read from GitHub, last {days} days"
136
+
137
+
138
+ def delivery_inputs(repo: str, days: int, tok: str | None, home: Path, make_api=ActionsAPI, make_gql=None,
139
+ now: datetime | None = None, log=None) -> tuple[dict, dict, dict, list[dict], dict]:
140
+ """What delivery is computed from: this machine's copies of a repo's pull requests, repo details, CI runs and (when
141
+ kept) issues, brought up to date from GitHub, and the repo's own settings file."""
142
+ from stackvitals.core.config import FILE, parse
143
+ from stackvitals.flow.store import FlowStore
144
+ now = now or datetime.now(timezone.utc)
145
+ try:
146
+ text = make_api(tok, repo, max_requests=5).file(FILE, None).decode("utf-8", "replace")
147
+ except APIError:
148
+ text = None
149
+ cfg = parse(text)[0]
150
+ ci, flow, _state = local_stores(repo, days, tok, home, make_api, make_gql, now, log=log)
151
+ folder = Path(home) / "flow" / repo.replace("/", "__")
152
+ return flow, FlowStore(folder).repo(), ci, local_issues(home, repo), cfg
153
+
154
+
155
+ def local_issues(home: Path, repo: str) -> list[dict]:
156
+ """The issues `community report` keeps on this machine (and the older place next to the PRs), for incidents."""
157
+ from stackvitals.flow.store import FlowStore
158
+ slug = repo.replace("/", "__")
159
+ for root, folder in ((Path(home) / "community" / slug, "issues"), (Path(home) / "flow" / slug, "community/issues")):
160
+ if (root / folder).is_dir():
161
+ got = FlowStore(root, folder).load()
162
+ return got["open"] + got["closed"]
163
+ return []
164
+
165
+
166
+ def findings(repo: str, days: int, tok: str | None, home: Path, make_api=ActionsAPI, make_gql=None,
167
+ now: datetime | None = None, log=None) -> tuple[list[dict], str]:
168
+ """What to fix first in a repo: its published findings when it runs StackVitals, else computed from this machine's
169
+ copies of its CI runs, pull requests and issues, with Team Flow's public numbers only."""
170
+ from stackvitals.core import findings as found_mod
171
+ from stackvitals.core import insights
172
+ from stackvitals.core.delivery import build as delivery_build
173
+ from stackvitals.flow.build import build as flow_build
174
+ now = now or datetime.now(timezone.utc)
175
+ try:
176
+ found = accept(json.loads(make_api(tok, repo, max_requests=20).file("docs/insights.json", BRANCH)))
177
+ except (APIError, ValueError):
178
+ found = None
179
+ if found is not None and found.get("findings") is not None:
180
+ return found["findings"], "stackvitals-data branch"
181
+ flow, info, ci, issues, cfg = delivery_inputs(repo, days, tok, home, make_api=make_api, make_gql=make_gql, now=now, log=log)
182
+ state = DataStore(Path(home) / "ci" / repo.replace("/", "__")).state()
183
+ prs = flow["open"] + flow["closed"]
184
+ red = insights.red_ci(prs, ci, now, window_days=days, state=state)
185
+ meta = {path: set(v.get("jobs") or []) for path, v in (state.get("triggers") or {}).items()}
186
+ public_flow = flow_build(flow, info, now, owner=repo.split("/")[0])
187
+ bumps = found_mod.bump_reds(prs, ci, now, meta, since=red["since"])
188
+ ledger = None
189
+ if bumps:
190
+ from stackvitals.deps.evidence import load_ledger
191
+ ledger = load_ledger(tok, Path(home) / "deps")
192
+ out = found_mod.build(repo, red, delivery_build(flow, info, ci, issues, now, days, cfg), public_flow, bumps, now, ledger)
193
+ return out, f"pull requests and CI runs read from GitHub, last {days} days"
194
+
195
+
196
+ def supply(repo: str, tok: str | None, make_api=ActionsAPI) -> tuple[dict | None, str]:
197
+ """A repo's supply chain as its StackVitals run published it (it needs the repo's own dependency graph and a weekly
198
+ run, so nothing is computed from here)."""
199
+ try:
200
+ found = accept(json.loads(make_api(tok, repo, max_requests=5).file("docs/supply.json", BRANCH)))
201
+ except (APIError, ValueError):
202
+ found = None
203
+ return found, "stackvitals-data branch" if found else "not published: the repo doesn't run StackVitals with the deps module"
@@ -0,0 +1,96 @@
1
+ """.github/stackvitals.toml from the repo's default branch, read once per job for every step: a conditional request makes
2
+ an unchanged file free, so a merged change applies on the next run. A missing file means the defaults; a failed read keeps
3
+ the last good settings, which live in the data branch (written only when they change, so quiet runs commit nothing)."""
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ from stackvitals.ci.api import ActionsAPI, APIError, NotFound, OutOfBudget
10
+ from stackvitals.core.config import FILE, parse
11
+ from stackvitals.core.time import iso
12
+
13
+ STATE = "settings/current.json"
14
+
15
+
16
+ @dataclass
17
+ class Settings:
18
+ cfg: dict
19
+ notes: list[str] = field(default_factory=list)
20
+ text: str | None = None
21
+ changed: bool = False
22
+
23
+
24
+ def _kept(root: Path) -> tuple[dict, bool]:
25
+ """The last good text (and whether it came from this file, so its ETag can be trusted), else the old daily cache."""
26
+ try:
27
+ got = json.loads((root / STATE).read_text(encoding="utf-8"))
28
+ if isinstance(got, dict):
29
+ return got, True
30
+ except (OSError, ValueError):
31
+ pass
32
+ try:
33
+ old = json.loads((root / "ci" / "state.json").read_text(encoding="utf-8")).get("settings")
34
+ if isinstance(old, dict):
35
+ return {"text": old.get("text")}, False
36
+ except (OSError, ValueError, AttributeError):
37
+ pass
38
+ return {}, False
39
+
40
+
41
+ def load(api, root: Path, now: datetime) -> Settings:
42
+ kept, ours = _kept(root)
43
+ try:
44
+ content, etag = api.file_if_changed(FILE, None, kept.get("etag") if ours else None)
45
+ except NotFound:
46
+ content, etag = None, None
47
+ text = None
48
+ except (APIError, OutOfBudget) as exc:
49
+ cfg, notes = parse(kept.get("text"))
50
+ return Settings(cfg, notes + [f"{FILE} couldn't be read this run ({exc}); the last good settings were used."],
51
+ kept.get("text"))
52
+ else:
53
+ text = kept.get("text") if content is None else content.decode("utf-8", "replace")
54
+ cfg, notes = parse(text)
55
+ if text and notes and "couldn't be read" in notes[0] and kept.get("text") and kept.get("text") != text:
56
+ # A typo must not switch the defaults (team automation is on by default) back on: keep the last good settings.
57
+ good, _ = parse(kept["text"])
58
+ return Settings(good, [notes[0].replace("the defaults were used", "the last good settings were used until it's fixed")],
59
+ kept.get("text"))
60
+ changed = text != kept.get("text") or not ours
61
+ if changed or etag != kept.get("etag"):
62
+ path = root / STATE
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ path.write_text(json.dumps({"text": text, "etag": etag, "changed_at": iso(now) if changed else kept.get("changed_at")},
65
+ indent=1), encoding="utf-8")
66
+ return Settings(cfg, notes, text, changed and text != kept.get("text"))
67
+
68
+
69
+ def read(api) -> dict:
70
+ """The settings without keeping anything (a pull request's check has no data branch); trouble means the defaults."""
71
+ try:
72
+ content, _etag = api.file_if_changed(FILE)
73
+ return parse(content.decode("utf-8", "replace") if content else None)[0]
74
+ except Exception: # noqa: BLE001 - a pull request's check never fails over the settings file
75
+ return parse(None)[0]
76
+
77
+
78
+ def prepare(env: dict, make_api=ActionsAPI):
79
+ """The job's first act on a fresh checkout: the settings every step reads from `ws.cache["settings"]`."""
80
+ def run(ws) -> None:
81
+ api = make_api(env.get("STACKVITALS_TOKEN") or env.get("GITHUB_TOKEN"), ws.repo, max_requests=3)
82
+ got = load(api, ws.path, ws.now)
83
+ try:
84
+ # The repo's details once per job (default branch, visibility, Pages), for the app's files; CI reuses them.
85
+ ws.cache["repo_info"] = api._get(f"/repos/{ws.repo}")
86
+ except Exception: # noqa: BLE001 - without them CI reads them itself and the app files say less
87
+ pass
88
+ ws.cache["settings"], ws.cache["settings_notes"], ws.cache["settings_changed"] = got.cfg, got.notes, got.changed
89
+ if got.notes:
90
+ def summary():
91
+ path = env.get("GITHUB_STEP_SUMMARY")
92
+ if path:
93
+ with open(path, "a", encoding="utf-8") as f:
94
+ f.write("### StackVitals settings\n" + "\n".join(f"- {n}" for n in got.notes) + "\n")
95
+ ws.after_commit(summary, "Settings")
96
+ return run
@@ -0,0 +1,111 @@
1
+ """`stackvitals sql "<query>"`: SQL over a repo's summaries (its data branch, or a folder) with DuckDB; each summary file
2
+ is a table named after it (prs, ci_jobs, tests)."""
3
+ import argparse
4
+ import json
5
+ import re
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from stackvitals.ci import cli as ci_cli
11
+ from stackvitals.ci.action import BRANCH
12
+ from stackvitals.ci.api import APIError, ActionsAPI
13
+ from stackvitals.core.summary import FOLDER, table_types
14
+
15
+ # A summary file name from a data branch becomes a local file and a table name, so only plain names are taken.
16
+ SAFE_NAME = re.compile(r"[a-z0-9_]+\.jsonl")
17
+ HELP = "SQL over a repo's StackVitals summaries. Tables: prs, ci_jobs, tests (one row per PR, job or test)."
18
+
19
+
20
+ def _fetch(make_api, tok: str | None, repo: str, into: Path) -> Path | None:
21
+ api = make_api(tok, repo, max_requests=20)
22
+ try:
23
+ manifest = json.loads(api.file(f"{FOLDER}/manifest.json", BRANCH))
24
+ except (APIError, ValueError):
25
+ return None
26
+ folder = (into / FOLDER).resolve()
27
+ folder.mkdir(parents=True, exist_ok=True)
28
+ files = manifest.get("files") if isinstance(manifest, dict) else None
29
+ for name in (files if isinstance(files, (dict, list)) else []):
30
+ target = folder / name if isinstance(name, str) and SAFE_NAME.fullmatch(name) else None
31
+ if target is None or target.resolve().parent != folder:
32
+ print(f"Skipped {name!r} in {repo}'s manifest: not a plain summary file name.", file=sys.stderr)
33
+ continue
34
+ target.write_bytes(api.file(f"{FOLDER}/{name}", BRANCH))
35
+ return into
36
+
37
+
38
+ def _ident(name: str) -> str:
39
+ return '"' + name.replace('"', '""') + '"'
40
+
41
+
42
+ def main(argv: list[str] | None = None, make_api=ActionsAPI, tok: str | None = None) -> int:
43
+ parser = argparse.ArgumentParser(prog="stackvitals sql", description=HELP)
44
+ parser.add_argument("query", help="the SQL to run, e.g. \"select repo, role, count(*) from prs group by all\"")
45
+ parser.add_argument("--repo", action="append", default=[],
46
+ help="owner/name: read the summaries from its stackvitals-data branch (repeat for several repos)")
47
+ parser.add_argument("--dir", action="append", default=[],
48
+ help="a folder holding summary/ (a clone of the data branch; repeat for several)")
49
+ parser.add_argument("--json", action="store_true", help="print rows as JSON")
50
+ args = parser.parse_args(argv)
51
+ try:
52
+ import duckdb
53
+ except ImportError:
54
+ print("DuckDB is needed for SQL: pip install 'stackvitals[sql]'", file=sys.stderr)
55
+ return 2
56
+ with tempfile.TemporaryDirectory(prefix="stackvitals-sql-") as tmp:
57
+ sources = []
58
+ if args.repo:
59
+ tok = tok or ci_cli.token()
60
+ for repo in args.repo:
61
+ root = _fetch(make_api, tok, repo, Path(tmp) / repo.replace("/", "__"))
62
+ if root is None:
63
+ print(f"No summaries found in {repo}'s {BRANCH} branch (does StackVitals run there?).", file=sys.stderr)
64
+ return 1
65
+ sources.append((repo, root))
66
+ for folder in args.dir or ([] if args.repo else ["."]):
67
+ root = Path(folder)
68
+ if not (root / FOLDER).is_dir():
69
+ print(f"No summaries under {root} (expected a {FOLDER}/ folder).", file=sys.stderr)
70
+ return 1
71
+ sources.append((root.resolve().name, root))
72
+ return _run(duckdb, sources, args.query, args.json)
73
+
74
+
75
+ def _run(duckdb, sources: list[tuple[str, Path]], query: str, as_json: bool) -> int:
76
+ con = duckdb.connect()
77
+ tables = sorted({p.stem for _label, root in sources for p in (root / FOLDER).glob("*.jsonl") if SAFE_NAME.fullmatch(p.name)})
78
+ for name in tables:
79
+ # One table per summary over every repo, with a `repo` column; repos missing a column get nulls (BY NAME).
80
+ parts = []
81
+ for label, root in sources:
82
+ path = root / FOLDER / f"{name}.jsonl"
83
+ if path.is_file() and path.stat().st_size:
84
+ # CREATE VIEW takes no bound parameters, so the path and label are quoted as literals.
85
+ literal = str(path).replace("\\", "/").replace("'", "''")
86
+ types = table_types(name)
87
+ spec = ", ".join(f"'{c}': '{t}'" for c, t in types.items()) if types else ""
88
+ reader = (f"read_json('{literal}', format='newline_delimited', columns={{{spec}}})" if types
89
+ else f"read_json_auto('{literal}')")
90
+ parts.append(f"SELECT '{label.replace(chr(39), chr(39) * 2)}' AS repo, * FROM {reader}")
91
+ body = " UNION ALL BY NAME ".join(parts) if parts else "SELECT NULL AS repo, NULL AS empty WHERE false"
92
+ # Tables, not views: the files are read now, before file access is switched off for the query.
93
+ con.execute(f"CREATE TABLE {_ident(name)} AS {body}")
94
+ # The query can't read, write or attach files, and can't switch that back on.
95
+ con.execute("SET enable_external_access = false")
96
+ con.execute("SET lock_configuration = true")
97
+ try:
98
+ cur = con.execute(query)
99
+ except duckdb.Error as exc:
100
+ print(f"SQL error: {exc}", file=sys.stderr)
101
+ return 1
102
+ columns = [d[0] for d in cur.description]
103
+ rows = cur.fetchall()
104
+ if as_json:
105
+ print(json.dumps([dict(zip(columns, r)) for r in rows], default=str, indent=1))
106
+ return 0
107
+ widths = [max(len(str(c)), *(len(str(r[i])) for r in rows)) if rows else len(str(c)) for i, c in enumerate(columns)]
108
+ print(" ".join(str(c).ljust(w) for c, w in zip(columns, widths)))
109
+ for r in rows:
110
+ print(" ".join(str(v).ljust(w) for v, w in zip(r, widths)))
111
+ return 0