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,285 @@
1
+ """The suite's typed entities, with Apache DevLake's domain names and enums (so its SQL runs on our exports) and
2
+ OpenTelemetry's attribute names for CI/CD and VCS columns. Built from the records the modules already store, so nothing
3
+ is collected twice; new code reads these, old code keeps its dicts until it moves over."""
4
+ from dataclasses import dataclass, field
5
+
6
+ from stackvitals.core.actors import kind_of
7
+ from stackvitals.core.time import secs
8
+
9
+ PR_STATES = ("OPEN", "MERGED", "CLOSED")
10
+ # DevLake's cicd result and status enums; GitHub's conclusions fold into them.
11
+ CICD_RESULTS = {"success": "SUCCESS", "failure": "FAILURE", "timed_out": "FAILURE", "startup_failure": "FAILURE",
12
+ "cancelled": "ABORT", "action_required": "MANUAL"}
13
+ # GitHub's own word for each result, for DevLake's original_result column.
14
+ ORIGINAL = {"SUCCESS": "success", "FAILURE": "failure", "ABORT": "cancelled", "MANUAL": "action_required"}
15
+ ISSUE_TYPES = ("REQUIREMENT", "BUG", "INCIDENT")
16
+ INCIDENT_LABELS = ("incident", "outage", "sev1", "sev2", "p0")
17
+ BUG_LABELS = ("bug", "defect", "regression")
18
+ EVENT_KINDS = {"ready": "READY", "draft": "DRAFT", "req": "REVIEW_REQUEST", "unreq": "REVIEW_REQUEST_REMOVED",
19
+ "review": "REVIEW", "merged": "MERGED", "closed": "CLOSED"}
20
+
21
+
22
+ def entity_id(entity: str, repo: str, key, source: str = "github") -> str:
23
+ # DevLake's id form: plugin:Entity:connection:id, with the repo as the connection.
24
+ return f"{source}:{entity}:{repo}:{key}"
25
+
26
+
27
+ def cicd_result(conclusion: str | None) -> str:
28
+ return CICD_RESULTS.get(conclusion or "", "OTHER")
29
+
30
+
31
+ @dataclass
32
+ class ChangeEvent:
33
+ kind: str # READY, DRAFT, REVIEW_REQUEST, REVIEW_REQUEST_REMOVED, COMMENT, REVIEW, MERGED, CLOSED
34
+ at: str
35
+ actor: str | None = None
36
+ actor_kind: str = "unknown"
37
+ state: str | None = None # review state: APPROVED, CHANGES_REQUESTED, COMMENTED
38
+ assoc: str | None = None
39
+ raised: bool = False # the review or comment asked the author something
40
+
41
+
42
+ @dataclass
43
+ class ChangeRequest:
44
+ """DevLake's pull_requests entity with its timeline."""
45
+ id: str
46
+ repo: str
47
+ number: int
48
+ title: str
49
+ state: str
50
+ draft: bool
51
+ author: str | None
52
+ author_kind: str
53
+ author_assoc: str | None
54
+ created_at: str | None
55
+ ready_at: str | None
56
+ merged_at: str | None
57
+ closed_at: str | None
58
+ updated_at: str | None
59
+ additions: int | None
60
+ deletions: int | None
61
+ changed_files: int | None
62
+ base_ref: str | None
63
+ head_ref: str | None
64
+ head_owner: str | None
65
+ last_commit_at: str | None
66
+ paths: list[str] = field(default_factory=list)
67
+ labels: list[str] = field(default_factory=list)
68
+ requested: list[str] = field(default_factory=list)
69
+ events: list[ChangeEvent] = field(default_factory=list)
70
+ linked_issue: bool = False
71
+
72
+ @classmethod
73
+ def from_flow(cls, rec: dict, repo: str) -> "ChangeRequest":
74
+ from stackvitals.flow.analyze import ready_at
75
+ events = []
76
+ for e in rec.get("ev") or []:
77
+ kind = EVENT_KINDS.get(e[0])
78
+ if not kind:
79
+ continue
80
+ actor = e[2] if len(e) > 2 else None
81
+ if e[0] == "review":
82
+ events.append(ChangeEvent(kind, e[1], actor, kind_of(actor, e[5] if len(e) > 5 else None), e[3],
83
+ e[4] if len(e) > 4 else None, bool(e[6]) if len(e) > 6 else False))
84
+ else:
85
+ events.append(ChangeEvent(kind, e[1], actor, kind_of(actor) if actor else "unknown"))
86
+ for c in rec.get("cm") or []:
87
+ if c and c[0]:
88
+ events.append(ChangeEvent("COMMENT", c[0], c[1], kind_of(c[1], c[3] if len(c) > 3 else None), None,
89
+ c[2] if len(c) > 2 else None, bool(c[4]) if len(c) > 4 else False))
90
+ events.sort(key=lambda e: e.at)
91
+ return cls(entity_id("PullRequest", repo, rec["n"]), repo, rec["n"], rec.get("ti") or "", rec.get("st") or "OPEN",
92
+ bool(rec.get("draft")), rec.get("au"), kind_of(rec.get("au"), rec.get("at")), rec.get("as"), rec.get("cr"),
93
+ ready_at(rec), rec.get("mg"), rec.get("cl"), rec.get("up"), rec.get("add"), rec.get("del"), rec.get("files"),
94
+ rec.get("base"), rec.get("head"), rec.get("ho"), rec.get("lc"), list(rec.get("paths") or []),
95
+ list(rec.get("lb") or []), list(rec.get("rq") or []), events, bool(rec.get("iss")))
96
+
97
+ def to_devlake(self) -> dict:
98
+ # DevLake's pull_requests columns, so its dashboards' SQL runs on an export of ours.
99
+ return {"id": self.id, "pull_request_key": self.number, "title": self.title, "status": self.state,
100
+ "author_name": self.author, "author_id": entity_id("Account", self.repo, self.author or "") if self.author else None,
101
+ "created_date": self.created_at, "merged_date": self.merged_at, "closed_date": self.closed_at,
102
+ "additions": self.additions, "deletions": self.deletions, "is_draft": self.draft,
103
+ "base_ref": self.base_ref, "head_ref": self.head_ref,
104
+ "url": f"https://github.com/{self.repo}/pull/{self.number}"}
105
+
106
+
107
+ @dataclass
108
+ class Step:
109
+ number: int | None
110
+ name: str | None
111
+ result: str
112
+ started_at: str | None
113
+ finished_at: str | None
114
+
115
+
116
+ @dataclass
117
+ class Task:
118
+ """DevLake's cicd_tasks: one job of a run attempt."""
119
+ id: str
120
+ name: str | None
121
+ result: str
122
+ created_at: str | None
123
+ started_at: str | None
124
+ finished_at: str | None
125
+ labels: list[str] = field(default_factory=list)
126
+ steps: list[Step] = field(default_factory=list)
127
+
128
+ @property
129
+ def duration_s(self) -> float | None:
130
+ return secs(self.started_at, self.finished_at)
131
+
132
+
133
+ @dataclass
134
+ class PipelineRun:
135
+ """DevLake's cicd_pipelines: one attempt of a workflow run."""
136
+ id: str
137
+ repo: str
138
+ run_id: int
139
+ attempt: int
140
+ name: str | None
141
+ path: str | None
142
+ trigger: str | None
143
+ branch: str | None
144
+ sha: str | None
145
+ tree: str | None
146
+ change_numbers: list[int]
147
+ head_owner: str | None
148
+ result: str
149
+ created_at: str | None
150
+ started_at: str | None
151
+ updated_at: str | None
152
+ tasks: list[Task] = field(default_factory=list)
153
+
154
+ @classmethod
155
+ def from_ci(cls, run: dict, jobs: list[dict], repo: str) -> "PipelineRun":
156
+ key = f"{run['id']}:{run.get('att', 1)}"
157
+ tasks = [Task(entity_id("CicdTask", repo, f"{key}:{j['id']}"), j.get("n"), cicd_result(j.get("c")), j.get("cr"),
158
+ j.get("st"), j.get("en"), list(j.get("lb") or []),
159
+ [Step(s[0], s[1], cicd_result(s[2]), *(list(s[3:5]) + [None, None])[:2]) for s in j.get("steps") or []])
160
+ for j in jobs if j.get("run") == run["id"] and j.get("att", 1) == run.get("att", 1)]
161
+ return cls(entity_id("CicdPipeline", repo, key), repo, run["id"], run.get("att", 1), run.get("wfn"), run.get("path"),
162
+ run.get("ev"), run.get("br"), run.get("sha"), run.get("tree"), list(run.get("pr") or []), run.get("ho"),
163
+ cicd_result(run.get("c")), run.get("cr"), run.get("st"), run.get("up"), tasks)
164
+
165
+ def to_devlake(self, scope: str) -> dict:
166
+ """DevLake's cicd_pipelines row, its cicd_tasks rows (this attempt's jobs) and its cicd_pipeline_commits row."""
167
+ whole = lambda s: None if s is None else int(round(s)) # noqa: E731
168
+ url = f"https://github.com/{self.repo}/actions/runs/{self.run_id}" + (f"/attempts/{self.attempt}" if self.attempt > 1 else "")
169
+ pipeline = {"id": self.id, "name": self.name, "result": self.result, "status": "DONE",
170
+ "original_status": "completed", "original_result": ORIGINAL.get(self.result, self.result.lower()),
171
+ "duration_sec": whole(secs(self.started_at, self.updated_at)),
172
+ "queued_duration_sec": whole(secs(self.created_at, self.started_at)), "created_date": self.created_at,
173
+ "queued_date": self.created_at, "started_date": self.started_at, "finished_date": self.updated_at,
174
+ "environment": None, "type": None, "cicd_scope_id": scope, "display_title": self.name, "url": url}
175
+ tasks = [{"id": t.id, "name": t.name, "pipeline_id": self.id, "result": t.result, "status": "DONE",
176
+ "original_status": "completed", "original_result": ORIGINAL.get(t.result, t.result.lower()), "type": None,
177
+ "environment": None, "duration_sec": whole(t.duration_s), "queued_duration_sec": whole(secs(t.created_at, t.started_at)),
178
+ "started_date": t.started_at, "finished_date": t.finished_at, "created_date": t.created_at,
179
+ "queued_date": t.created_at, "cicd_scope_id": scope} for t in self.tasks]
180
+ commit = {"pipeline_id": self.id, "commit_sha": self.sha, "branch": self.branch, "repo_id": scope,
181
+ "repo_url": f"https://github.com/{self.repo}"}
182
+ return {"cicd_pipelines": pipeline, "cicd_tasks": tasks, "cicd_pipeline_commits": [commit] if self.sha else []}
183
+
184
+ def to_otel(self) -> dict:
185
+ # OpenTelemetry's CI/CD and VCS attribute names (semantic conventions, release candidate).
186
+ return {"cicd.pipeline.name": self.name, "cicd.pipeline.run.id": self.run_id, "cicd.pipeline.run.attempt": self.attempt,
187
+ "cicd.pipeline.result": self.result, "cicd.pipeline.run.state": "finished",
188
+ "vcs.repository.name": self.repo, "vcs.ref.head.name": self.branch, "vcs.ref.head.revision": self.sha,
189
+ "vcs.change.id": self.change_numbers[0] if self.change_numbers else None}
190
+
191
+
192
+ @dataclass
193
+ class TestResult:
194
+ """One test's outcome in one run attempt, with CTRF's field names in to_ctrf()."""
195
+ test_id: str
196
+ status: str # PASSED, FAILED, FLAKY, SKIPPED
197
+ repo: str
198
+ run_id: int
199
+ attempt: int
200
+ artifact: int | None = None
201
+ suite: str | None = None
202
+ fingerprint: str | None = None
203
+ message: str | None = None
204
+ duration_ms: int | None = None
205
+
206
+ @classmethod
207
+ def from_report(cls, rec: dict, repo: str) -> list["TestResult"]:
208
+ run, att, art, suite = rec.get("run"), rec.get("att", 1), rec.get("art"), rec.get("name")
209
+ slow = {tid: ms for tid, ms in rec.get("slow") or []}
210
+ out = [cls(tid, "FAILED", repo, run, att, art, suite, fp, msg, slow.get(tid)) for tid, fp, msg in rec.get("failed") or []]
211
+ out += [cls(tid, "FLAKY", repo, run, att, art, suite, None, None, slow.get(tid)) for tid in rec.get("flaky") or []]
212
+ return out
213
+
214
+ def to_ctrf(self) -> dict:
215
+ status = {"FAILED": "failed", "FLAKY": "passed", "SKIPPED": "skipped"}.get(self.status, "passed")
216
+ out = {"name": self.test_id, "status": status, "duration": self.duration_ms or 0, "flaky": self.status == "FLAKY",
217
+ "suite": self.suite}
218
+ if self.message:
219
+ out["message"] = self.message
220
+ return out
221
+
222
+
223
+ @dataclass
224
+ class Issue:
225
+ """DevLake's issues: type REQUIREMENT, BUG or INCIDENT; status TODO or DONE."""
226
+ id: str
227
+ repo: str
228
+ number: int
229
+ type: str
230
+ status: str
231
+ created_at: str | None
232
+ closed_at: str | None
233
+ author: str | None
234
+ author_kind: str
235
+ labels: list[str] = field(default_factory=list)
236
+
237
+ @classmethod
238
+ def from_community(cls, rec: dict, repo: str, incident_labels=INCIDENT_LABELS, bug_labels=BUG_LABELS) -> "Issue":
239
+ labels = [str(x).lower() for x in rec.get("lb") or []]
240
+ kind = "INCIDENT" if any(x in labels for x in incident_labels) else "BUG" if any(x in labels for x in bug_labels) \
241
+ else "REQUIREMENT"
242
+ return cls(entity_id("Issue", repo, rec["n"]), repo, rec["n"], kind, "DONE" if rec.get("cl") else "TODO", rec.get("cr"),
243
+ rec.get("cl"), rec.get("au"), kind_of(rec.get("au"), rec.get("at")), labels)
244
+
245
+
246
+ @dataclass
247
+ class Release:
248
+ id: str
249
+ repo: str
250
+ tag: str
251
+ published_at: str | None
252
+ sha: str | None = None
253
+
254
+
255
+ @dataclass
256
+ class Deployment:
257
+ """DevLake's cicd_deployment_commits: a successful production deployment of one commit."""
258
+ id: str
259
+ repo: str
260
+ sha: str | None
261
+ finished_at: str | None
262
+ source: str # workflow, api, release
263
+ environment: str = "PRODUCTION"
264
+ result: str = "SUCCESS"
265
+ name: str | None = None
266
+
267
+ @classmethod
268
+ def new(cls, repo: str, key: str, sha: str | None, finished_at: str | None, source: str, name: str | None = None) -> "Deployment":
269
+ return cls(entity_id("CicdDeployment", repo, key), repo, sha, finished_at, source, name=name)
270
+
271
+
272
+ @dataclass
273
+ class Incident:
274
+ """An incident (issue labelled incident, a revert or a hotfix PR) linked to the deployment before it, or to none."""
275
+ id: str
276
+ repo: str
277
+ opened_at: str | None
278
+ resolved_at: str | None
279
+ source: str # issue, revert, hotfix
280
+ deployment_id: str | None = None
281
+
282
+ @classmethod
283
+ def new(cls, repo: str, key: str, opened_at: str | None, resolved_at: str | None, source: str,
284
+ deployment_id: str | None = None) -> "Incident":
285
+ return cls(entity_id("Incident", repo, key), repo, opened_at, resolved_at, source, deployment_id)
@@ -0,0 +1,66 @@
1
+ """`stackvitals project NAME owner/a owner/b ...`: one project's DORA across several repos, as an Apache DevLake project
2
+ counts it (its own SQL on the repos' tables pooled), next to each repo's own; no DevLake, MySQL or Grafana running."""
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import tempfile
7
+ from datetime import datetime, timedelta, timezone
8
+
9
+ import httpx
10
+
11
+ from mergescout.github.graphql import GraphQLError
12
+ from stackvitals.ci import cli as ci_cli
13
+ from stackvitals.ci.api import APIError, OutOfBudget
14
+ from stackvitals.core import export, services
15
+
16
+ PANELS = ("deployment_frequency", "lead_time", "change_failure_rate", "recovery")
17
+
18
+
19
+ def project_dora(name: str, tables_by_repo: dict, start: datetime, end: datetime, report: str = "2023") -> dict:
20
+ """{"project": DevLake's four panels on the pooled tables, "repos": {repo: its own four}}."""
21
+ def four(tables: dict, project: str) -> dict:
22
+ with tempfile.TemporaryDirectory() as tmp:
23
+ export.write(tables, tmp)
24
+ got = export.panels(tmp, start, end, report, project)
25
+ return {k: got.get(k) for k in PANELS}
26
+ return {"project": four(export.pool(tables_by_repo, name), name),
27
+ "repos": {repo: four(t, repo) for repo, t in tables_by_repo.items()}}
28
+
29
+
30
+ def main(argv: list[str] | None = None, tok: str | None = None) -> int:
31
+ ap = argparse.ArgumentParser(prog="stackvitals project", description="DORA for one project across several repos.")
32
+ ap.add_argument("name", help="the project's name")
33
+ ap.add_argument("repos", nargs="+", help="owner/name of each repo in it")
34
+ ap.add_argument("--days", type=int, default=90)
35
+ ap.add_argument("--json", action="store_true")
36
+ a = ap.parse_args(argv)
37
+ tok = tok or ci_cli.token()
38
+ if not tok:
39
+ print("A GitHub token is needed. Set GITHUB_TOKEN, or run `gh auth login`.", file=sys.stderr)
40
+ return 1
41
+ now = datetime.now(timezone.utc)
42
+ days = max(1, min(a.days, 90))
43
+ tables, report = {}, "2023"
44
+ for repo in a.repos:
45
+ try:
46
+ flow, info, ci, issues, cfg = services.delivery_inputs(repo, days, tok, ci_cli.home(), now=now,
47
+ log=lambda line: print(line, file=sys.stderr, flush=True))
48
+ except (APIError, OutOfBudget, GraphQLError, httpx.HTTPError) as exc:
49
+ print(f"Could not read {repo}: {exc}", file=sys.stderr)
50
+ return 1
51
+ report = cfg["delivery"]["dora_report"]
52
+ tables[repo] = export.devlake_tables(repo, flow, info, ci, issues, cfg)
53
+ try:
54
+ out = project_dora(a.name, tables, now - timedelta(days=days), now, report)
55
+ except ImportError:
56
+ print("This needs DuckDB: pip install 'stackvitals[sql]'.", file=sys.stderr)
57
+ return 1
58
+ if a.json:
59
+ print(json.dumps({"name": a.name, "days": days, **out}, indent=1, default=str))
60
+ return 0
61
+ print(f"{a.name}: {len(a.repos)} repos, last {days} days, DORA as Apache DevLake counts a project:")
62
+ for k in PANELS:
63
+ print(f" {k.replace('_', ' '):24} {out['project'][k]}")
64
+ for repo, p in out["repos"].items():
65
+ print(f" {repo}: " + "; ".join(f"{k.replace('_', ' ')} {p[k]}" for k in PANELS))
66
+ return 0
@@ -0,0 +1,86 @@
1
+ """Agents propose, people submit: the same prefilled request issue the app opens, for a settings change and/or a
2
+ `/stackvitals` command, checked first with the Action's own parsers so a bad value never reaches GitHub. Nothing is sent:
3
+ a person opens the link and presses Submit, and the workflow applies it if they have the access."""
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from dataclasses import asdict
8
+ from datetime import date, datetime, timezone
9
+ from urllib.parse import quote
10
+
11
+ from stackvitals.core import decisions as dec
12
+ from stackvitals.core.tomledit import EditError, apply
13
+
14
+ URL_MAX = 7500
15
+
16
+
17
+ class ProposalError(ValueError):
18
+ pass
19
+
20
+
21
+ def body(changes: list[dict], settings: dict, note: str = "") -> str:
22
+ """The issue text, the same as the app's write.js builds."""
23
+ block = json.dumps({"version": 1, "changes": changes, "settings": settings}, separators=(",", ":"))
24
+ return (f"{note + chr(10) + chr(10) if note else ''}Sent from the StackVitals app. The StackVitals workflow applies this when "
25
+ f"someone with access opens it, answers here and closes it.\n\n```stackvitals\n{block}\n```\n")
26
+
27
+
28
+ def proposal(repo: str, settings: dict | None = None, command: str | None = None, note: str = "", today: date | None = None) -> dict:
29
+ """{url, title, note}: a link to a prefilled request issue; ProposalError when the change can't be applied."""
30
+ today = today or datetime.now(timezone.utc).date()
31
+ settings = settings or {}
32
+ try:
33
+ decided = dec.parse_command(command or "", today) if command else []
34
+ except dec.CommandError as exc:
35
+ raise ProposalError(str(exc)) from None
36
+ if command and not decided:
37
+ raise ProposalError("A command starts with /stackvitals, for example: /stackvitals mute test:<id> for 7d because <reason>.")
38
+ if not decided and not settings:
39
+ raise ProposalError("Nothing to propose: give settings to change and/or a /stackvitals command.")
40
+ if settings:
41
+ try:
42
+ apply("", settings) # every key must exist and every value be one the parser accepts
43
+ except EditError as exc:
44
+ raise ProposalError(str(exc)) from None
45
+ changes = [asdict(d) for d in decided]
46
+ what = ", ".join([*(f"{d.verb} {d.kind} {d.id}" for d in decided), *settings])
47
+ title = f"StackVitals: {what}"[:120]
48
+ url = f"https://github.com/{repo}/issues/new?title={quote(title)}&body={quote(body(changes, settings, note))}"
49
+ if len(url) > URL_MAX:
50
+ raise ProposalError("This change is too long for one link; split it into smaller ones.")
51
+ need = "write" if settings else "triage or write"
52
+ return {"url": url, "title": title,
53
+ "note": f"Open the link and press Submit; the StackVitals workflow applies it when someone with {need} access opened it."}
54
+
55
+
56
+ def _value(text: str):
57
+ try:
58
+ return json.loads(text)
59
+ except ValueError:
60
+ return text # a plain word, like a regex or a day name
61
+
62
+
63
+ def main(argv: list[str] | None = None) -> int:
64
+ parser = argparse.ArgumentParser(prog="stackvitals propose",
65
+ description="Print a link that proposes a settings change or a decision; a person submits it.")
66
+ parser.add_argument("repo", help="owner/name")
67
+ parser.add_argument("--set", action="append", default=[], metavar="KEY=VALUE",
68
+ help="a setting, e.g. alerts.pickup_p75_hours=6 or automation.issues=true (JSON values)")
69
+ parser.add_argument("--decide", help='a command, e.g. "/stackvitals mute test:<id> for 7d because <reason>"')
70
+ parser.add_argument("--note", default="", help="a line of context for the person who submits it")
71
+ args = parser.parse_args(argv)
72
+ settings = {}
73
+ for item in args.set:
74
+ key, sep, value = item.partition("=")
75
+ if not sep:
76
+ print(f"--set needs KEY=VALUE, not {item!r}", file=sys.stderr)
77
+ return 2
78
+ settings[key.strip()] = _value(value.strip())
79
+ try:
80
+ out = proposal(args.repo, settings, args.decide, args.note)
81
+ except ProposalError as exc:
82
+ print(f"Not proposed: {exc}", file=sys.stderr)
83
+ return 1
84
+ print(out["note"])
85
+ print(out["url"])
86
+ return 0
@@ -0,0 +1,85 @@
1
+ """A weekly developer pulse in the digest issue (SPACE's satisfaction and DX Core 4's experience, the GitHub-native way):
2
+ three questions posted as comments that people answer with a reaction, counted the next week. StackVitals keeps and
3
+ shows only the counts; GitHub itself shows who reacted to anyone who can see the issue. On a public repo only the
4
+ team's own people (insiders) are counted."""
5
+
6
+ QUESTIONS = (("flow", "Could you get your work done this week without waiting on others?"),
7
+ ("friction", "Did CI and reviews stay out of your way this week?"),
8
+ ("satisfaction", "Are you happy with how the team shipped this week?"))
9
+ ANSWERS = {"+1": "yes", "-1": "no", "confused": "mixed"}
10
+ NAMES = {"flow": "Flow", "friction": "Low friction", "satisfaction": "Satisfaction"}
11
+
12
+
13
+ def _marker(week: str, key: str) -> str:
14
+ return f"<!-- stackvitals:pulse {week} {key} -->"
15
+
16
+
17
+ def ask(api, issue: int, week: str) -> None:
18
+ for key, question in QUESTIONS:
19
+ api.send("POST", f"/repos/{api.repo}/issues/{issue}/comments",
20
+ {"body": f"**Pulse:** {question}\n\nReact 👍 yes, 👎 no, 😕 mixed. StackVitals reads only the counts (reactions themselves are visible on GitHub).\n{_marker(week, key)}"})
21
+
22
+
23
+ def tally(api, issue: int, week: str, since: str | None = None, keep=None) -> dict:
24
+ """Reaction counts on that week's three questions (`keep(login)` picks whose count); a question not found counts
25
+ nothing. Up to 300 reactions a question."""
26
+ comments = api._get(f"/repos/{api.repo}/issues/{issue}/comments", {"per_page": 100, **({"since": since} if since else {})})
27
+ out = {key: {"yes": 0, "no": 0, "mixed": 0} for key, _q in QUESTIONS}
28
+ for c in comments or []:
29
+ key = next((k for k, _q in QUESTIONS if _marker(week, k) in (c.get("body") or "")), None)
30
+ if not key:
31
+ continue
32
+ for page in (1, 2, 3):
33
+ got = api._get(f"/repos/{api.repo}/issues/comments/{c['id']}/reactions", {"per_page": 100, "page": page}) or []
34
+ for r in got:
35
+ if r.get("content") in ANSWERS and (keep is None or keep((r.get("user") or {}).get("login"))):
36
+ out[key][ANSWERS[r["content"]]] += 1
37
+ if len(got) < 100:
38
+ break
39
+ return out
40
+
41
+
42
+ def section(counts: dict) -> str | None:
43
+ """The digest's pulse lines, or None when nobody answered."""
44
+ if not any(sum(v.values()) for v in counts.values()):
45
+ return None
46
+ parts = [f"{NAMES[k]} {v['yes']} yes, {v['no']} no" + (f", {v['mixed']} mixed" if v["mixed"] else "")
47
+ for k, v in counts.items() if sum(v.values())]
48
+ return "**Team pulse last week** (reactions, counts only): " + "; ".join(parts) + "."
49
+
50
+
51
+ KEEP_WEEKS = 52
52
+ MIN_ANSWERS = 5 # fewer answers to a question in a week and no share is shown: too few to read, too easy to trace
53
+ # The number each question sits beside in the app: waiting on others / CI and reviews in the way / how the team shipped.
54
+ BESIDE = {"flow": "pickup", "friction": "first_try", "satisfaction": "deployments"}
55
+
56
+
57
+ def record(root, week: str, counts: dict) -> None:
58
+ """Keep one week's counts (and nothing about who answered) in docs/pulse.json, a year at most."""
59
+ import json
60
+ from pathlib import Path
61
+
62
+ from stackvitals.core.store import published, write_if_changed
63
+ path = Path(root) / "docs" / "pulse.json"
64
+ try:
65
+ weeks = json.loads(path.read_text(encoding="utf-8")).get("weeks") or []
66
+ except (OSError, ValueError):
67
+ weeks = []
68
+ clean = {k: {a: int((counts.get(k) or {}).get(a) or 0) for a in ("yes", "no", "mixed")} for k, _q in QUESTIONS}
69
+ weeks = [w for w in weeks if w.get("week") != week] + [{"week": week, **clean}]
70
+ weeks = sorted(weeks, key=lambda w: w["week"])[-KEEP_WEEKS:]
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ write_if_changed(path, published({"weeks": weeks, "beside": BESIDE, "min_answers": MIN_ANSWERS}))
73
+
74
+
75
+ def trend(weeks: list[dict]) -> dict:
76
+ """{question: [{"week", "answers", "yes_share"}]}; the share is None for a week with fewer than 5 answers."""
77
+ out = {}
78
+ for key, _q in QUESTIONS:
79
+ rows = []
80
+ for w in weeks:
81
+ c = w.get(key) or {}
82
+ n = sum(int(c.get(a) or 0) for a in ("yes", "no", "mixed"))
83
+ rows.append({"week": w["week"], "answers": n, "yes_share": round(c.get("yes", 0) / n, 4) if n >= MIN_ANSWERS else None})
84
+ out[key] = rows
85
+ return out