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,528 @@
1
+ """Changes people ask for on GitHub, applied by the Action at the start of the job: a request issue from the app (a fenced
2
+ ```stackvitals block), `/stackvitals` commands in comments, a request handed to a manual run (the app's token mode), and
3
+ a flaky-test issue closed as not planned. Only people with the right access change anything (triage for decisions,
4
+ write for settings), every change is logged with who and why, and the answer goes on the issue after the data is saved."""
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ from datetime import datetime, timedelta
9
+ from pathlib import Path
10
+
11
+ from stackvitals.ci.api import ActionsAPI, APIError, OutOfBudget
12
+ from stackvitals.core import decisions as dec
13
+ from stackvitals.core.config import FILE, parse
14
+ from stackvitals.core.digest import _code, _quiet
15
+ from stackvitals.core.time import iso
16
+ from stackvitals.core.tomledit import EditError, apply
17
+
18
+ EVENTS = ("issues", "issue_comment", "workflow_dispatch")
19
+ LEVELS = {"none": 0, "read": 0, "triage": 1, "write": 2, "maintain": 3, "admin": 4}
20
+ ITEMS = (("Flaky test: ", "test"), ("Flaky job: ", "job"))
21
+
22
+
23
+ def _item(title: str | None) -> tuple[str, str] | None:
24
+ for prefix, kind in ITEMS:
25
+ if (title or "").startswith(prefix):
26
+ return kind, title[len(prefix):]
27
+ return None
28
+
29
+
30
+ def seen_state(result: dict | None, kind: str, ident: str) -> dict | None:
31
+ """What StackVitals said about a test or job in its newest published findings, kept with feedback on that verdict:
32
+ its state and the verdict a failure of it would get now."""
33
+ from stackvitals.ci.automation import job_key
34
+ from stackvitals.ci.feedback import SAFE_FLAKES, _test_verdict, _verdict
35
+ if not result:
36
+ return None
37
+ if kind == "test":
38
+ item = next((t for t in result.get("tests") or [] if t.get("id") == ident), None)
39
+ verdict = _test_verdict(item) if item else None
40
+ else:
41
+ jobs = result.get("jobs") or []
42
+ # Exact names first; the pull request comment writes "<workflow display name> / <job>", so a unique job name
43
+ # at the end matches too.
44
+ item = (next((j for j in jobs if ident in (job_key(j), j.get("job"))), None)
45
+ or (lambda found: found[0] if len(found) == 1 else None)([j for j in jobs if ident.endswith(f" / {j.get('job')}")]))
46
+ verdict = _verdict(item) if item else None
47
+ if verdict == "known flaky" and item.get("flake_events", 0) < SAFE_FLAKES:
48
+ verdict = "flaked before"
49
+ return {"state": item.get("state"), "flake_events": item.get("flake_events", 0), "verdict": verdict} if item else None
50
+
51
+
52
+ def _published(ws) -> dict | None:
53
+ try:
54
+ return json.loads((Path(ws.path) / "docs" / "data.json").read_text(encoding="utf-8"))
55
+ except (OSError, ValueError):
56
+ return None
57
+
58
+
59
+ def _payload(env: dict) -> dict:
60
+ try:
61
+ return json.loads(Path(env["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8"))
62
+ except (KeyError, OSError, ValueError):
63
+ return {}
64
+
65
+
66
+ def _level(api, login: str | None) -> int:
67
+ if not login:
68
+ return 0
69
+ try:
70
+ body = api._get(f"/repos/{api.repo}/collaborators/{login}/permission")
71
+ except (APIError, OutOfBudget):
72
+ return 0
73
+ # The legacy field maps triage to read and maintain to write; role_name holds the real role.
74
+ role = body.get("role_name")
75
+ return LEVELS[role] if role in LEVELS else LEVELS.get(body.get("permission") or "none", 0)
76
+
77
+
78
+ def _b64(text: str) -> str:
79
+ return base64.b64encode(text.encode("utf-8")).decode("ascii")
80
+
81
+
82
+ def _get(cfg: dict, key: str):
83
+ for part in key.split("."):
84
+ cfg = cfg[part]
85
+ return cfg
86
+
87
+
88
+ def _shown(value) -> str:
89
+ return "(not set)" if value is None else json.dumps(value)
90
+
91
+
92
+ def _changes_line(before: dict, after: dict, keys) -> str:
93
+ return "; ".join(f"{_code(k)}: {_code(_shown(_get(before, k)))} to {_code(_shown(_get(after, k)))}" for k in keys)
94
+
95
+
96
+ def _moves(before: dict, settings: dict) -> dict:
97
+ # What changed per key for the log; a null in the request puts the default back.
98
+ return {k: f"{_shown(_get(before, k))} to {'the default' if v is None else json.dumps(v)}" for k, v in settings.items()}
99
+
100
+
101
+ def _apply_settings(api, settings: dict, login: str, number: int | None, now: datetime) -> tuple[str, dict | None, dict]:
102
+ """(answer, the new settings when they took effect now, what changed per key for the log): a commit on the default
103
+ branch, else a branch with a pull request (or a link to open one) when the branch is protected."""
104
+ info = api._get(f"/repos/{api.repo}")
105
+ branch = info.get("default_branch") or "main"
106
+ try:
107
+ got = api._get(f"/repos/{api.repo}/contents/{FILE}", {"ref": branch})
108
+ sha, text = got.get("sha"), base64.b64decode(got.get("content") or "").decode("utf-8")
109
+ except APIError as exc:
110
+ if "404" not in str(exc):
111
+ raise
112
+ sha, text = None, ""
113
+ try:
114
+ new = apply(text, settings)
115
+ except EditError as exc:
116
+ return f"Nothing was changed: {_quiet(exc)}", None, {}
117
+ if new == text:
118
+ return f"{FILE} already says that; nothing to change.", None, {}
119
+ what = _changes_line(parse(text)[0], parse(new)[0], settings)
120
+ moves = _moves(parse(text)[0], settings)
121
+ proposed = {k: f"{m}, proposed in a pull request" for k, m in moves.items()}
122
+ asked = f"asked in #{number}" if number else "asked from the StackVitals app"
123
+ body = {"message": f"chore(stackvitals): Update settings ({asked} by {login})", "content": _b64(new), "branch": branch,
124
+ **({"sha": sha} if sha else {})}
125
+ try:
126
+ api.send("PUT", f"/repos/{api.repo}/contents/{FILE}", body)
127
+ return f"Changed in `{FILE}` on `{branch}`: {what}. It applies from this run.", parse(new)[0], moves
128
+ except APIError as exc:
129
+ if not any(code in str(exc) for code in ("409", "422", "403")):
130
+ raise
131
+ head = f"stackvitals/settings-{number or now.strftime('%Y%m%d%H%M%S')}"
132
+ base = api._get(f"/repos/{api.repo}/git/ref/heads/{branch}")["object"]["sha"]
133
+ try:
134
+ api.send("POST", f"/repos/{api.repo}/git/refs", {"ref": f"refs/heads/{head}", "sha": base})
135
+ except APIError:
136
+ pass # left by an earlier attempt of this same request
137
+ api.send("PUT", f"/repos/{api.repo}/contents/{FILE}", {**body, "branch": head})
138
+ title = f"Update StackVitals settings ({asked})"
139
+ try:
140
+ pr = api.send("POST", f"/repos/{api.repo}/pulls", {"title": title, "head": head, "base": branch,
141
+ "body": f"Settings change {asked} by {login}: {what}."})
142
+ return f"`{branch}` is protected, so the change waits in a pull request: {pr.get('html_url')} ({what}).", None, proposed
143
+ except APIError:
144
+ link = f"https://github.com/{api.repo}/compare/{branch}...{head}?quick_pull=1"
145
+ return (f"`{branch}` is protected and this repo doesn't let Actions open pull requests; open it here: {link} ({what}).",
146
+ None, proposed)
147
+
148
+
149
+ def _decided(changes: list[dec.Decision]) -> str:
150
+ words = {"quarantine": "quarantined", "unquarantine": "taken out of quarantine", "mute": "muted", "unmute": "unmuted",
151
+ "acknowledge": "acknowledged", "snooze": "snoozed", "reopen": "reopened", "exclude": "excluded from the numbers",
152
+ "include": "counted again", "accept": "accepted as a known risk",
153
+ "confirm": "confirmed: StackVitals' verdict was right", "dispute": "disputed: StackVitals' verdict was wrong"}
154
+ undo = {"quarantine": "unquarantine", "mute": "unmute", "snooze": "reopen", "acknowledge": "reopen", "exclude": "include",
155
+ "accept": "reopen"}
156
+ out = []
157
+ for d in changes:
158
+ line = f"{d.kind} {_code(d.id)} {words[d.verb]}" + (f" until {d.until}" if d.until else "")
159
+ if d.reason:
160
+ line += f" ({_quiet(d.reason)})"
161
+ if d.verb in undo:
162
+ line += f"; undo with `/stackvitals {undo[d.verb]} {d.kind}:{d.id.replace('`', '')}`"
163
+ out.append("- " + line)
164
+ return "\n".join(out)
165
+
166
+
167
+ SWEEP = "decisions/requests.json"
168
+ LOOKBACK = timedelta(hours=6)
169
+ TRUSTED = ("OWNER", "MEMBER", "COLLABORATOR")
170
+ DONE_MAX = 500
171
+
172
+
173
+ def _commanded(body: str | None) -> bool:
174
+ return any(line.strip().lower().startswith("/stackvitals") for line in (body or "").splitlines())
175
+
176
+
177
+ def _missed(api, since: str, done: set) -> list[dict]:
178
+ """Commands and request issues from people with access that no run applied (GitHub cancels a waiting run when a
179
+ newer one queues): the newest comments and open issues since `since`, two requests."""
180
+ out = []
181
+ comments = api._get(f"/repos/{api.repo}/issues/comments", {"since": since, "sort": "created", "direction": "desc",
182
+ "per_page": 100}) or []
183
+ for c in reversed(comments):
184
+ key = f"comment:{c.get('id')}"
185
+ if (key not in done and (c.get("created_at") or "") >= since and c.get("author_association") in TRUSTED
186
+ and _commanded(c.get("body"))):
187
+ number = int(str(c.get("issue_url") or "/0").rsplit("/", 1)[-1] or 0)
188
+ out.append({"key": key, "kind": "comment", "number": number, "login": (c.get("user") or {}).get("login"),
189
+ "body": c.get("body") or ""})
190
+ for i in reversed(api._get(f"/repos/{api.repo}/issues", {"state": "open", "since": since, "per_page": 100}) or []):
191
+ key = f"issue:{i.get('number')}"
192
+ if (key not in done and "pull_request" not in i and (i.get("created_at") or "") >= since
193
+ and i.get("author_association") in TRUSTED and "```stackvitals" in (i.get("body") or "")):
194
+ out.append({"key": key, "kind": "issue", "number": i["number"], "login": (i.get("user") or {}).get("login"),
195
+ "body": i.get("body") or ""})
196
+ return out
197
+
198
+
199
+ def _from_event(event: str, payload: dict, env: dict) -> dict | None:
200
+ issue = payload.get("issue") or {}
201
+ if event == "issues" and payload.get("action") == "opened":
202
+ return {"key": f"issue:{issue.get('number')}", "kind": "issue", "number": issue.get("number"),
203
+ "login": (issue.get("user") or {}).get("login"), "body": issue.get("body") or ""}
204
+ if event == "issue_comment" and payload.get("action") == "created":
205
+ comment = payload.get("comment") or {}
206
+ # On a flaky-test issue the command is about its test; on a pull request, about that pull request.
207
+ default = _item(issue.get("title")) or (("pr", str(issue.get("number"))) if issue.get("pull_request") else None)
208
+ return {"key": f"comment:{comment.get('id')}", "kind": "comment", "number": issue.get("number"),
209
+ "login": (comment.get("user") or {}).get("login"), "body": comment.get("body") or "", "default": default}
210
+ if event == "workflow_dispatch":
211
+ raw = ((payload.get("inputs") or {}).get("request") or "").strip()
212
+ if raw:
213
+ return {"key": None, "kind": "dispatch", "number": None, "body": raw,
214
+ "login": (payload.get("sender") or {}).get("login") or env.get("GITHUB_ACTOR")}
215
+ return None
216
+
217
+
218
+ APPROVE = "/stackvitals approve"
219
+ WAITING = "Waiting for approval:"
220
+
221
+
222
+ def approval_code(body: str | None) -> str:
223
+ # Ties an approval to the request as it reads now: an edit to the issue changes the code, so the approval must be given again.
224
+ return hashlib.sha256(" ".join(str(body or "").split()).encode()).hexdigest()[:8]
225
+
226
+
227
+ def _golden(env: dict, api, golden: dict, login: str, approver: str | None = None, body: str | None = None,
228
+ issue: bool = True) -> str:
229
+ """Runs a Portal golden path with the org App, from the portal repo's portal.toml; says what happened or why not. A
230
+ path with `approvals = 1` waits until someone else with write access comments the approval."""
231
+ from stackvitals.portal.golden import GoldenError, load_paths, run
232
+ token = (env.get("STACKVITALS_PORTAL_TOKEN") or "").strip()
233
+ if not token:
234
+ return "Golden paths run with the Portal's org App (the portal-token input); nothing was run."
235
+ try:
236
+ paths = load_paths(api.file("portal.toml", None).decode("utf-8", "replace"))
237
+ except (APIError, GoldenError, ValueError) as exc:
238
+ return f"The golden paths in portal.toml couldn't be read ({_quiet(exc)}); nothing was run."
239
+ path = paths.get(golden["path"])
240
+ if not path:
241
+ return f"portal.toml has no golden path {_code(golden['path'])}; nothing was run."
242
+ if path.get("approvals") and not approver:
243
+ if not issue:
244
+ return (f"Golden path {_code(path['id'])} needs someone else's approval, given on a request issue: send it "
245
+ "from the app as a request (not \"run now\"); nothing was run.")
246
+ return (f"{WAITING} golden path {_code(path['id'])} runs once someone else with write access to this repo "
247
+ f"comments `{APPROVE} {approval_code(body)}` here (the code changes if the request is edited).")
248
+ org = (env.get("STACKVITALS_PORTAL_ORG") or "").strip() or api.repo.split("/")[0]
249
+ try:
250
+ text, url = run(ActionsAPI(token, api.repo, max_requests=60), api, api.repo, org, path, golden["inputs"], login)
251
+ except (GoldenError, APIError, OutOfBudget) as exc:
252
+ return f"Golden path {_code(path['id'])} stopped: {_quiet(str(exc)[:200])}"
253
+ return f"{text} {url}".strip()
254
+
255
+
256
+ def _approve(ws, api, store, item: dict, now: datetime, say, env: dict) -> dict:
257
+ """`/stackvitals approve` on a waiting golden path request: someone with write access other than the requester lets it
258
+ run, as the requester (whose own access the golden path still checks)."""
259
+ number, approver = item.get("number"), item.get("login")
260
+ issue = api._get(f"/repos/{api.repo}/issues/{number}") if number else {}
261
+ requester = (issue.get("user") or {}).get("login")
262
+ try:
263
+ req = dec.parse_request(issue.get("body") or "", now.date())
264
+ except dec.CommandError:
265
+ req = None
266
+ if not req or not req.golden or issue.get("state") != "open":
267
+ say(number, "There is no golden path waiting for approval here; nothing was run.", None)
268
+ return {"decisions": 0, "settings": False}
269
+ if approver == requester:
270
+ say(number, "A golden path needs someone other than the person who asked for it to approve; nothing was run.", None)
271
+ return {"decisions": 0, "settings": False, "refused": True}
272
+ if _level(api, approver) < 2 or _level(api, requester) < 2:
273
+ say(number, "Approving needs write access to this repo, for both the approver and the requester; nothing was run.", None)
274
+ return {"decisions": 0, "settings": False, "refused": True}
275
+ given = (item.get("body") or "").strip()[len(APPROVE):].split()
276
+ code = approval_code(issue.get("body"))
277
+ if not given or given[0].lower() != code:
278
+ say(number, f"The approval needs the request's current code: comment `{APPROVE} {code}` (it changes when the request "
279
+ "is edited, so what was read is what runs); nothing was run.", None)
280
+ return {"decisions": 0, "settings": False, "refused": True}
281
+ memo = getattr(ws, "memo", {})
282
+ key = ("golden", f"approved:{number}")
283
+ if key not in memo:
284
+ memo[key] = _golden(env, api, req.golden, requester, approver=approver)
285
+ store.record([dec.Decision("run", "golden", req.golden["path"], None, f"approved by {approver}: {memo[key][:180]}")],
286
+ by=requester, source=f"comment on #{number}", now=now)
287
+ say(number, f"Approved by {approver}. {memo[key]}", "completed")
288
+ return {"decisions": 1, "settings": False}
289
+
290
+
291
+ def _apply(ws, api, store, item: dict, now: datetime, say, env: dict | None = None) -> dict:
292
+ """One request or command: parsed, checked against the sender's access, recorded, settings committed; `say` answers."""
293
+ today, number, login = now.date(), item.get("number"), item.get("login")
294
+ source = {"issue": f"issue #{number}", "comment": f"comment on #{number}"}.get(item["kind"], "manual run")
295
+ if item["kind"] == "comment" and item["body"].strip().lower().startswith(APPROVE):
296
+ return _approve(ws, api, store, item, now, say, env or {})
297
+ try:
298
+ asked = _check_command(item["body"]) if item["kind"] == "comment" else None
299
+ again = _resync_command(item["body"]) if item["kind"] == "comment" else None
300
+ if asked is not None:
301
+ req = dec.Request([], {}, check=asked)
302
+ elif again is not None:
303
+ req = dec.Request([], {}, resync=again)
304
+ elif item["kind"] == "comment":
305
+ default = item.get("default")
306
+ if "default" not in item and number:
307
+ found = api._get(f"/repos/{api.repo}/issues/{number}")
308
+ default = _item(found.get("title")) or (("pr", str(number)) if found.get("pull_request") else None)
309
+ changes = dec.parse_command(item["body"], today, default=default)
310
+ req = dec.Request(changes, {}) if changes else None
311
+ else:
312
+ raw = item["body"]
313
+ block = raw if item["kind"] == "issue" or "```stackvitals" in raw else f"```stackvitals\n{raw}\n```"
314
+ req = dec.parse_request(block, today)
315
+ except dec.CommandError as exc:
316
+ say(number, f"StackVitals couldn't read that: {_quiet(exc)} Nothing was changed.", None)
317
+ return {"decisions": 0, "settings": False, "error": str(exc)}
318
+ if req is None or not (req.changes or req.settings or req.golden or req.check or req.resync):
319
+ return {"decisions": 0, "settings": False}
320
+ # A connection test uses the repo's keys and may post to its chat, and a re-sync spends their limits: write access.
321
+ need = 2 if req.settings or req.golden or req.check or req.resync else 1
322
+ if _level(api, login) < need:
323
+ say(number, ("Only people with write access to this repo can change its StackVitals settings" if need == 2 else
324
+ "Only people with triage or write access can make StackVitals decisions here") + "; nothing was changed.",
325
+ None)
326
+ return {"decisions": 0, "settings": False, "refused": True}
327
+ lines, changed, moves = [], False, {}
328
+ if req.changes:
329
+ feedback = [c for c in req.changes if c.verb in dec.FEEDBACK]
330
+ published = _published(ws) if feedback else None
331
+ seen = {(c.kind, c.id): seen_state(published, c.kind, c.id) for c in feedback}
332
+ store.record(req.changes, by=login, source=source, now=now, seen=seen)
333
+ lines += ["Recorded:", _decided(req.changes)]
334
+ if req.settings:
335
+ memo = getattr(ws, "memo", {})
336
+ key = ("settings", item["key"] or source)
337
+ if key not in memo:
338
+ # Once per job: a rejected data push runs this step again, and the file on GitHub already changed.
339
+ memo[key] = _apply_settings(api, req.settings, login, number, now)
340
+ text, cfg, moves = memo[key]
341
+ lines.append(text)
342
+ # Settings changes join the log too, so the app's Activity shows who changed what, and how.
343
+ store.record([dec.Decision("set", "setting", k, None, why) for k, why in moves.items()], by=login, source=source, now=now)
344
+ if cfg is not None:
345
+ ws.cache["settings"], changed = cfg, True
346
+ if req.golden:
347
+ memo = getattr(ws, "memo", {})
348
+ key = ("golden", item["key"] or source)
349
+ if key not in memo:
350
+ # Once per job, like settings: a retried data push must never create a repo or a PR twice.
351
+ memo[key] = _golden(env or {}, api, req.golden, login, body=item.get("body"), issue=item["kind"] == "issue")
352
+ lines.append(memo[key])
353
+ store.record([dec.Decision("run", "golden", req.golden["path"], None, memo[key][:200])], by=login, source=source,
354
+ now=now)
355
+ checked = None
356
+ if req.check:
357
+ memo = getattr(ws, "memo", {})
358
+ key = ("check", item["key"] or source)
359
+ if key not in memo:
360
+ # Once per job: a retried data push must not post the chat test message twice.
361
+ memo[key] = _run_checks(ws, req.check, env or {})
362
+ checked = memo[key]
363
+ if isinstance(checked, dict):
364
+ from stackvitals.connectors import check, public
365
+ check.save(ws.path, checked, public(ws))
366
+ shown = check.shown(checked, public(ws))
367
+ lines += ["Connection checks:", *(f"- {check.line(n, r)}" for n, r in shown.items())] if shown else \
368
+ ["No connector is set up yet, so there was nothing to test."]
369
+ else:
370
+ lines.append(checked)
371
+ resynced = 0
372
+ if req.resync:
373
+ from stackvitals.connectors import ConnectorError
374
+ from stackvitals.core.resync import rewind
375
+ lines.append("Read again:")
376
+ for r in req.resync:
377
+ # The connectors step runs after this one, so the same run reads the window again.
378
+ try:
379
+ lines.append(f"- {rewind(ws.path, r['source'].strip().lower(), r['since'], now)}")
380
+ resynced += 1
381
+ except (ConnectorError, ValueError) as exc:
382
+ lines.append(f"- {r['source']}: {_quiet(exc)}")
383
+ store.record([dec.Decision("run", "resync", r["source"], None, f"since {r['since'] or 'the whole window'}")
384
+ for r in req.resync], by=login, source=source, now=now)
385
+ waiting = bool(req.golden) and any(line.startswith(WAITING) for line in lines)
386
+ close = (None if waiting else "completed" if req.changes or moves or req.golden or req.check or resynced
387
+ else "not_planned") if item["kind"] == "issue" else None
388
+ say(number, "\n".join(lines), close)
389
+ out = {"decisions": len(req.changes), "settings": changed, **({"resynced": resynced} if req.resync else {})}
390
+ if isinstance(checked, dict):
391
+ out.update(checks=len(checked), working=sum(1 for r in checked.values() if r["ok"]))
392
+ return out
393
+
394
+
395
+ def _run_checks(ws, asked, env: dict) -> dict | str:
396
+ """The asked connection checks, or a sentence when the names can't be read."""
397
+ from stackvitals.connectors import check, public
398
+ cfg = ws.cache.get("settings") or parse("")[0]
399
+ try:
400
+ names = check.resolve(asked, cfg, env)
401
+ except ValueError as exc:
402
+ return f"Nothing was tested: {_quiet(exc)}"
403
+ # A public repo's answers name no channel, team or bucket: they land on a readable branch and issue.
404
+ return check.run(names, cfg, env, public=public(ws))
405
+
406
+
407
+ def _check_command(body: str | None):
408
+ """`/stackvitals check jira slack` (or `check` / `check all`): the names asked for, else None."""
409
+ for line in (body or "").splitlines():
410
+ words = line.strip().split()
411
+ if words and words[0].lower() == "/stackvitals":
412
+ if len(words) > 1 and words[1].lower() == "check":
413
+ rest = [w.strip(",") for w in words[2:] if w.strip(",")]
414
+ return "all" if not rest or [w.lower() for w in rest] == ["all"] else rest
415
+ return None
416
+ return None
417
+
418
+
419
+ def _resync_command(body: str | None) -> list | None:
420
+ """`/stackvitals resync jira linear since 2026-09-01`: the services to read again and from when, else None."""
421
+ for line in (body or "").splitlines():
422
+ words = [w.strip(",") for w in line.strip().split() if w.strip(",")]
423
+ if words and words[0].lower() == "/stackvitals":
424
+ if len(words) < 3 or words[1].lower() != "resync":
425
+ return None
426
+ since = words[words.index("since") + 1] if "since" in words[:-1] else None
427
+ names = [w.lower() for w in words[2:] if w not in ("since", since)]
428
+ return [{"source": n, "since": since} for n in names] or None
429
+ return None
430
+
431
+
432
+ def check_only(env: dict) -> bool:
433
+ """Whether this run's own request only asks for connection checks, so the job can skip every module."""
434
+ item = _from_event(env.get("GITHUB_EVENT_NAME"), _payload(env), env)
435
+ if not item:
436
+ return False
437
+ if item["kind"] == "comment":
438
+ return _check_command(item["body"]) is not None
439
+ raw = item["body"]
440
+ block = raw if item["kind"] == "issue" or "```stackvitals" in raw else f"```stackvitals\n{raw}\n```"
441
+ try:
442
+ req = dec.parse_request(block, datetime.now().date())
443
+ except dec.CommandError:
444
+ return False
445
+ return bool(req and req.check and not (req.changes or req.settings or req.golden))
446
+
447
+
448
+ def _closed_or_reopened(store, payload: dict, now: datetime, api=None) -> dict | None:
449
+ """A flaky-test issue closed as not planned: the team lives with it; reopened: that decision is undone."""
450
+ issue = payload.get("issue") or {}
451
+ item = _item(issue.get("title"))
452
+ if not (item and any((label or {}).get("name") == "flaky-test" for label in issue.get("labels") or [])):
453
+ return None
454
+ by, source = (payload.get("sender") or {}).get("login") or "?", f"issue #{issue.get('number')}"
455
+ if payload.get("action") == "closed":
456
+ if issue.get("state_reason") != "not_planned":
457
+ return None
458
+ # An issue's author can close it too; only triage or write access makes it a team decision.
459
+ if api is None or _level(api, by if by != "?" else None) < 1:
460
+ print(f"{by} closed {source} as not planned without triage or write access; no decision was recorded.")
461
+ return {"decisions": 0, "settings": False, "refused": True}
462
+ store.record([dec.Decision("acknowledge", item[0], item[1], None, "closed as not planned")], by=by, source=source, now=now)
463
+ else:
464
+ current = store.state(now.date()).get(f"{item[0]}:{item[1]}") or {}
465
+ if current.get("verb") != "acknowledge":
466
+ return None
467
+ store.record([dec.Decision("reopen", item[0], item[1], None, "issue reopened")], by=by, source=source, now=now)
468
+ return {"decisions": 1, "settings": False}
469
+
470
+
471
+ def requests_step(ws, env: dict, now: datetime, make_api=ActionsAPI) -> dict | None:
472
+ event, payload = env.get("GITHUB_EVENT_NAME"), _payload(env)
473
+ store = dec.Store(ws.path)
474
+ api = make_api(env.get("STACKVITALS_TOKEN") or env.get("GITHUB_TOKEN"), ws.repo, max_requests=40)
475
+ if event == "issues" and payload.get("action") in ("closed", "reopened"):
476
+ return _closed_or_reopened(store, payload, now, api)
477
+ replies: list[tuple[int, str, str | None]] = []
478
+ unanswered: list[str] = []
479
+
480
+ def say(number, text, close):
481
+ if number:
482
+ replies.append((number, text, close))
483
+ else:
484
+ unanswered.append(text)
485
+
486
+ def answer():
487
+ for n, text, close in replies:
488
+ api.send("POST", f"/repos/{ws.repo}/issues/{n}/comments", {"body": text})
489
+ if close:
490
+ api.send("PATCH", f"/repos/{ws.repo}/issues/{n}", {"state": "closed", "state_reason": close})
491
+ # A manual run has no issue to answer on: its answer goes to the job summary.
492
+ for text in unanswered:
493
+ print(text)
494
+ if env.get("GITHUB_STEP_SUMMARY"):
495
+ with open(env["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f:
496
+ f.write(f"### StackVitals request\n{text}\n")
497
+ ws.after_commit(answer, "Requests")
498
+ path = ws.path / SWEEP
499
+ try:
500
+ kept = json.loads(path.read_text(encoding="utf-8"))
501
+ except (OSError, ValueError):
502
+ kept = None
503
+ done = list((kept or {}).get("done") or [])
504
+ own = _from_event(event, payload, env)
505
+ todo = [own] if own else []
506
+ if kept is not None:
507
+ since = max(now - LOOKBACK, datetime.fromisoformat(kept["since"].replace("Z", "+00:00")))
508
+ try:
509
+ todo += [m for m in _missed(api, iso(since), set(done)) if m["key"] != (own or {}).get("key")]
510
+ except (APIError, OutOfBudget):
511
+ pass # the next run looks again
512
+ totals = {"decisions": 0, "settings": False}
513
+ for item in todo:
514
+ out = _apply(ws, api, store, item, now, say, env)
515
+ totals["decisions"] += out["decisions"]
516
+ totals["settings"] = totals["settings"] or out["settings"]
517
+ totals.update({k: v for k, v in out.items() if k in ("error", "refused")})
518
+ for k in ("checks", "working", "resynced"):
519
+ if k in out:
520
+ totals[k] = totals.get(k, 0) + out[k]
521
+ if item["key"]:
522
+ done.append(item["key"])
523
+ # Written only when something new was handled (tracking starts with the first), so a quiet run commits nothing.
524
+ if len(done) != len((kept or {}).get("done") or []):
525
+ path.parent.mkdir(parents=True, exist_ok=True)
526
+ path.write_text(json.dumps({"since": (kept or {}).get("since") or iso(now), "done": done[-DONE_MAX:]}, indent=1),
527
+ encoding="utf-8")
528
+ return totals if todo else None
@@ -0,0 +1,35 @@
1
+ """Read one source again from a date: CI Health's runs, Team Flow's pull requests or a connected service. What was kept
2
+ stays; records read again replace theirs by id, so nothing is counted twice."""
3
+ from datetime import date, datetime
4
+ from pathlib import Path
5
+
6
+ # Names people use for the two GitHub sources.
7
+ CI, FLOW = {"ci", "ci health", "runs"}, {"flow", "team flow", "prs", "pull requests"}
8
+
9
+
10
+ def rewind(root: Path, source: str, since: str | None, now: datetime) -> str:
11
+ """Make the next collection of `source` read again from `since` (None: its whole window); a sentence saying what."""
12
+ from stackvitals.connectors import ConnectorError, rewind as rewind_connector
13
+ name = source.strip().lower()
14
+ day = date.fromisoformat(since) if since else None
15
+ if day and day > now.date():
16
+ raise ConnectorError(f"{since} is in the future; pick a past date to read again from.")
17
+ if name in CI:
18
+ from stackvitals.ci.store import DataStore
19
+ store = DataStore(root)
20
+ state = store.state()
21
+ # Days marked done are listed again; runs already kept are skipped, so only missing ones are fetched.
22
+ for key in ("scanned", "scanned_tests"):
23
+ state[key] = {d: v for d, v in (state.get(key) or {}).items() if day and d < day.isoformat()}
24
+ store.save_state(state)
25
+ return f"CI Health reads its runs again from {day.isoformat() if day else 'the start of its window'}."
26
+ if name in FLOW:
27
+ from stackvitals.flow.store import FlowStore
28
+ store = FlowStore(root)
29
+ state = {k: v for k, v in store.state().items() if k != "synced_up_to"}
30
+ if day:
31
+ state["synced_up_to"] = f"{day.isoformat()}T00:00:00Z"
32
+ store.save_state(state)
33
+ return (f"Team Flow reads pull requests updated since {day.isoformat()} again." if day else
34
+ "Team Flow reads its whole window of pull requests again.")
35
+ return rewind_connector(root, name.replace(".", "_") if name == "incident.io" else name, since, now)