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,253 @@
1
+ import html
2
+
3
+ from stackvitals import shell
4
+ from stackvitals.ci.bench import PEERS_ARE
5
+ from stackvitals.ci.report import _md as _md_ci
6
+
7
+ QUEUE_ROWS = 15
8
+ STAGE_NAMES = [("pickup", "Waiting for first review"), ("review", "First to final review"),
9
+ ("merge", "Approved to merged"), ("cycle", "Ready to merged")]
10
+
11
+
12
+ def _span(secs) -> str:
13
+ if secs is None:
14
+ return "-"
15
+ secs = int(secs)
16
+ if secs >= 86400:
17
+ return f"{secs // 86400}d {secs % 86400 // 3600}h"
18
+ if secs >= 3600:
19
+ return f"{secs // 3600}h {secs % 3600 // 60}m"
20
+ return f"{secs // 60}m"
21
+
22
+
23
+ def _md(text) -> str:
24
+ # PR titles and logins come from anyone who opens a PR: HTML and Markdown are both neutralised.
25
+ return _md_ci(html.escape(str(text), quote=False))
26
+
27
+
28
+ def _pct(x) -> str:
29
+ return "-" if x is None else f"{x:.0%}"
30
+
31
+
32
+ def _waiting_on(q: dict) -> str:
33
+ if q["actor"] == "reviewers":
34
+ return ", ".join(q["who"])
35
+ if q["actor"] == "author":
36
+ return f"the author ({q['author']})"
37
+ return "maintainers"
38
+
39
+
40
+ def _suggested(q: dict) -> str:
41
+ return "; ".join(f"{x['login']} ({x['why']})" for x in q.get("suggested") or [])
42
+
43
+
44
+ def _knowledge_lines(r: dict) -> list[str]:
45
+ k = r.get("knowledge")
46
+ if not k:
47
+ return []
48
+ one = [a for a in k["areas"] if a["single_owner"]]
49
+ quiet = [a for a in k["areas"] if (a.get("owner_quiet_days") or 0) > 90]
50
+ out = []
51
+ if one:
52
+ out.append("Known mostly by one person: " + ", ".join(
53
+ f"{a['area']} ({a['owner'] + ', ' if a.get('owner') else ''}{a['share']:.0%} of merged PRs)" for a in one[:8]))
54
+ if quiet:
55
+ out.append("Main author gone quiet: " + ", ".join(
56
+ f"{a['area']} ({a['owner'] + ', ' if a.get('owner') else ''}no merged PR for {a['owner_quiet_days']} days)" for a in quiet[:8]))
57
+ if k["hotspots"]:
58
+ out.append("Busiest areas, last 90 days: " + ", ".join(
59
+ f"{h['area']} ({h['prs_90d']} merged PRs)" for h in k["hotspots"][:5]))
60
+ return out
61
+
62
+
63
+ def stage_note(pages) -> str:
64
+ """How first review here differs from Delivery's pickup, which also counts a first comment (LinearB's definition)."""
65
+ where = "<a href='delivery.html'>Delivery's pickup</a>" if "delivery.html" in pages else "Delivery's pickup"
66
+ return (f"<p class=mut>First review counts reviews only. {where} also counts a first comment by someone else "
67
+ "(LinearB's definition), so it is usually shorter.</p>")
68
+
69
+
70
+ def _delivery_lines(r: dict) -> list[str]:
71
+ d = r.get("delivery")
72
+ if not d:
73
+ return []
74
+ if d["source"] == "merges":
75
+ return [f"No releases in the last {r['window_days']} days; {d['per_week']} merged PRs a week."]
76
+ cf = d["change_fail"]
77
+ later = f"; the next release came {_span(d['recovery_median_s'])} later (median)" if d["recovery_median_s"] else ""
78
+ return [f"{d['releases']} releases in {r['window_days']} days ({d['per_week']} a week); merge to release takes "
79
+ f"{_span(d['lead_time_median_s'])} (median).",
80
+ f"{cf['failed']} of {cf['of']} releases were followed by a revert or hotfix within 7 days "
81
+ f"(a proxy; the Delivery page counts change failure the way DevLake does, from incidents){later}."]
82
+
83
+
84
+ def _gaps(q: dict) -> str:
85
+ return "; ".join(g["why"] for g in q.get("gaps") or [])
86
+
87
+
88
+ def _habit_lines(r: dict) -> list[str]:
89
+ h = r.get("habits")
90
+ if not h or not h["merged_prs"]:
91
+ return []
92
+ inc, size = h["include"], h["size"]
93
+ out = [f"Merged PRs ({h['scope']}, n={h['merged_prs']}) include tests {inc['tests']:.0%}, a changelog entry "
94
+ f"{inc['changelog']:.0%}, docs {inc['docs']:.0%}; size {size['median_lines']} lines median, "
95
+ f"90% at most {size['p90_lines']} lines."]
96
+ if h.get("asks"):
97
+ out.append("Maintainers most often ask for: " + ", ".join(f"{a['ask']} ({a['share']:.0%} of reviewed PRs)" for a in h["asks"][:5]))
98
+ return out
99
+
100
+
101
+ def _peer_lines(r: dict) -> tuple[str, list[str]] | None:
102
+ c = r.get("peers")
103
+ if not c:
104
+ return None
105
+ fmt = {"hours": lambda v: _span(v * 3600), "share": lambda v: f"{v:.0%}", "lines": lambda v: f"{v:.0f} lines"}
106
+ lines = [f"{m['name']}: {fmt[m['kind']](m['you'])} (typical {fmt[m['kind']](m['typical'])}), {m['place']}"
107
+ for m in c["metrics"]]
108
+ loose = f", {c['fit']}" if c.get("fit") else ""
109
+ return (f"Compared with {int(c['repos'])} {c['group']} (PRs opened in the last 90 days{loose}); the group is "
110
+ f"{PEERS_ARE}, so it may not resemble a private team's work:"), lines
111
+
112
+
113
+ def _head(r: dict, repo: str) -> str:
114
+ return (f"{repo}: review flow over the last {r['window_days']} days ({r['merged']} merged, {r['closed_unmerged']} closed "
115
+ f"without merging, {r['open']} open of which {r['drafts']} drafts; bot PRs left out: {r['bots']['open']} open, "
116
+ f"{r['bots']['closed']} closed)")
117
+
118
+
119
+ def _groups(r: dict) -> list[str]:
120
+ out = []
121
+ for name, label in (("insider", "Insiders"), ("outsider", "Outside contributors")):
122
+ g = r["groups"][name]
123
+ out.append(f"{label}: {_pct(g['merge_rate'])} merged, first review after {_span(g['pickup']['median_s'])} "
124
+ f"(median, n={g['pickup']['n']})")
125
+ return out
126
+
127
+
128
+ def render_text(r: dict, repo: str) -> str:
129
+ out = [_head(r, repo), ""]
130
+ if peers := _peer_lines(r):
131
+ out += [peers[0], *[f" {line}" for line in peers[1]], ""]
132
+ out += ["Median time (90th percentile), GitHub's review stages:"]
133
+ out += [f" {label}: {_span(r['stages'][k]['median_s'])} ({_span(r['stages'][k]['p90_s'])}), n={r['stages'][k]['n']}"
134
+ for k, label in STAGE_NAMES]
135
+ out += [f" Merged without any human review: {r['merged_without_review']}", "", *_groups(r), "",
136
+ "Who acts next (overdue first; overdue = longer than usual for this repo, and over a day):"]
137
+ live = [q for q in r["queue"] if not q["stale"]]
138
+ for q in live[:QUEUE_ROWS]:
139
+ flag = " [overdue]" if q["slow"] else ""
140
+ out.append(f" #{q['n']} \"{q['title']}\" by {q['author']}{' (outsider)' if q['outsider'] else ''}: waiting on "
141
+ f"{_waiting_on(q)} for about {_span(q['waiting_s'])} (usual {_span(q['usual_s'])}) - {q['why']}{flag}")
142
+ if q.get("suggested"):
143
+ out.append(f" suggested reviewers: {_suggested(q)}")
144
+ if q.get("gaps"):
145
+ out.append(f" unlike merged PRs here: {_gaps(q)}")
146
+ if not live:
147
+ out.append(" nothing waiting")
148
+ if r["stale"]:
149
+ out.append(f" ... and {r['stale']} stale PRs waiting over 30 days")
150
+ if r["load"]:
151
+ out += ["", "Open review requests: " + ", ".join(f"{x['reviewer']} {x['open_requests']}" for x in r["load"][:10])]
152
+ if habits := _habit_lines(r):
153
+ out += ["", "What merged PRs here usually include:", *[f" {line}" for line in habits]]
154
+ if knowledge := _knowledge_lines(r):
155
+ out += ["", "Knowledge map:", *[f" {line}" for line in knowledge]]
156
+ if ship := _delivery_lines(r):
157
+ out += ["", "Delivery:", *[f" {line}" for line in ship]]
158
+ if r.get("responders"):
159
+ out += ["", "Answer times (private: shown only in this local view, never on the dashboard):"]
160
+ out += [f" {x['reviewer']}: {_span(x['median_s'])} median (n={x['n']})" for x in r["responders"][:10]]
161
+ return "\n".join(out)
162
+
163
+
164
+ def render_markdown(r: dict, repo: str) -> str:
165
+ out = [f"### StackVitals Team Flow: {_md(repo)}", _md(_head(r, repo).split(": ", 1)[1]), ""]
166
+ if peers := _peer_lines(r):
167
+ out += [_md(peers[0]), *[f"- {_md(line)}" for line in peers[1]], ""]
168
+ out += ["| Stage | Median | 90th percentile | PRs |", "|---|---|---|---|"]
169
+ out += [f"| {label} | {_span(r['stages'][k]['median_s'])} | {_span(r['stages'][k]['p90_s'])} | {r['stages'][k]['n']} |"
170
+ for k, label in STAGE_NAMES]
171
+ out += ["", *[_md(line) for line in _groups(r)], "", "| PR | Waiting on | For | Why |", "|---|---|---|---|"]
172
+ live = [q for q in r["queue"] if not q["stale"]]
173
+ out += [f"| #{q['n']} {_md(q['title'])} | {_md(_waiting_on(q))} | about {_span(q['waiting_s'])}{' (overdue)' if q['slow'] else ''} "
174
+ f"| {_md(q['why'])}{(' - suggested: ' + _md(', '.join(x['login'] for x in q['suggested']))) if q.get('suggested') else ''}"
175
+ f"{(' - ' + _md(_gaps(q))) if q.get('gaps') else ''} |"
176
+ for q in live[:QUEUE_ROWS]] or ["| - | nothing waiting | | |"]
177
+ if r["stale"]:
178
+ out.append(f"\n{r['stale']} stale PRs are waiting over 30 days.")
179
+ for title, lines in (("What merged PRs here usually include", _habit_lines(r)), ("Knowledge map", _knowledge_lines(r)),
180
+ ("Delivery", _delivery_lines(r))):
181
+ if lines:
182
+ out += ["", f"**{title}**", *[f"- {_md(line)}" for line in lines]]
183
+ return "\n".join(out)
184
+
185
+
186
+ def _hours(secs) -> float | None:
187
+ return None if secs is None else round(secs / 3600, 1)
188
+
189
+
190
+ def _trends(r: dict) -> str:
191
+ months = r.get("monthly") or []
192
+ if len(months) < 2:
193
+ return ""
194
+ labels = [m["month"] for m in months]
195
+ counts = shell.chart("PRs opened and merged per month", "Month", labels,
196
+ [("Opened", [m["opened"] for m in months]), ("Merged", [m["merged"] for m in months])], y="PRs", kind="bar")
197
+ times = shell.chart("Review times per month (median)", "Month", labels,
198
+ [("First review", [_hours(m.get("pickup_median_s")) for m in months]),
199
+ ("Ready to merged", [_hours(m.get("cycle_median_s")) for m in months])], y="hours", fmt=lambda v: f"{v:g} h")
200
+ return ("<h2>Trends</h2><p class=mut>The last 12 months; review times are for PRs merged that month, bots left out.</p>"
201
+ + shell.charts([counts, times]))
202
+
203
+
204
+ def render_html(r: dict, repo: str, pages=()) -> str:
205
+ e = html.escape
206
+ link = lambda n: f"<a href='https://github.com/{e(repo)}/pull/{int(n)}'>#{int(n)}</a>" # noqa: E731
207
+ body = []
208
+ cards = [("Open PRs", r["open"]), ("Merged", r["merged"]), ("First review (median)", _span(r["stages"]["pickup"]["median_s"])),
209
+ ("Ready to merged (median)", _span(r["stages"]["cycle"]["median_s"])), ("Stale backlog", f"{r['stale']} stale"),
210
+ ("Merged without review", r["merged_without_review"])]
211
+ body.append("<div class=cards>" + "".join(f"<div class=card><span class=mut>{e(k)}</span><b>{e(str(v))}</b></div>"
212
+ for k, v in cards) + "</div>")
213
+ if peers := _peer_lines(r):
214
+ body.append(f"<h2>Compared with similar repos</h2><p class=mut>{e(peers[0])}</p><ul>"
215
+ + "".join(f"<li>{e(line)}</li>" for line in peers[1]) + "</ul>")
216
+ body.append("<h2>Who acts next</h2><p class=mut>Overdue = waiting longer than usual for this repo, and over a day.</p>")
217
+ live = [q for q in r["queue"] if not q["stale"]]
218
+ if live:
219
+ body.append("<div class=tbl><table class=find><tr><th>PR</th><th>Waiting on</th><th>For</th><th>Usual</th><th>Why</th></tr>")
220
+ for q in live[:QUEUE_ROWS * 2]:
221
+ tag = " <span class='tag flaky'>overdue</span>" if q["slow"] else ""
222
+ body.append(f"<tr><td>{link(q['n'])} {e(q['title'] or '')}<br><span class=mut>by {e(q['author'] or '?')}"
223
+ f"{' (outsider)' if q['outsider'] else ''}</span></td><td>{e(_waiting_on(q))}</td>"
224
+ f"<td>about {_span(q['waiting_s'])}{tag}</td><td>{_span(q['usual_s'])}</td><td>{e(q['why'])}"
225
+ + (f"<br><span class=mut>Suggested: {e(_suggested(q))}</span>" if q.get("suggested") else "")
226
+ + (f"<br><span class=mut>Unlike merged PRs here: {e(_gaps(q))}</span>" if q.get("gaps") else "")
227
+ + "</td></tr>")
228
+ body.append("</table></div>")
229
+ else:
230
+ body.append("<p>Nothing waiting.</p>")
231
+ if r["stale"]:
232
+ body.append(f"<p class=mut>{r['stale']} stale PRs are waiting over 30 days.</p>")
233
+ if r.get("excluded"):
234
+ n = r["excluded"]
235
+ body.append(f"<p class=mut>{n} pull request{'' if n == 1 else 's'} left out by the team (the "
236
+ "<code>stackvitals: exclude</code> label or <code>/stackvitals exclude</code>).</p>")
237
+ body.append(_trends(r))
238
+ body.append("<h2>Review stages</h2><div class=tbl><table><tr><th>Stage</th><th>Median</th><th>90th percentile</th><th>PRs</th></tr>"
239
+ + "".join(f"<tr><td>{e(label)}</td><td>{_span(r['stages'][k]['median_s'])}</td><td>{_span(r['stages'][k]['p90_s'])}</td>"
240
+ f"<td>{r['stages'][k]['n']}</td></tr>" for k, label in STAGE_NAMES) + "</table></div>")
241
+ body.append(stage_note(pages))
242
+ body.append("<h2>Insiders and outside contributors</h2><ul>" + "".join(f"<li>{e(line)}</li>" for line in _groups(r)) + "</ul>")
243
+ for title, lines in (("What merged PRs here usually include", _habit_lines(r)), ("Knowledge map", _knowledge_lines(r)),
244
+ ("Delivery", _delivery_lines(r))):
245
+ if lines:
246
+ body.append(f"<h2>{title}</h2><ul>" + "".join(f"<li>{e(line)}</li>" for line in lines) + "</ul>")
247
+ if r["load"]:
248
+ body.append("<h2>Open review requests</h2><ul>" + "".join(
249
+ f"<li>{e(x['reviewer'])}: {x['open_requests']} open</li>" for x in r["load"][:20]) + "</ul>")
250
+ body.append("<footer class=mut>Stages follow GitHub's own boundaries; bots, authors and Copilot reviews are left out. "
251
+ "History is kept in this repo's <code>stackvitals-data</code> branch. Made by StackVitals.</footer>")
252
+ return shell.page("Team Flow", repo, "flow.html", f"Last {r['window_days']} days, updated {e(r['generated'])}",
253
+ "".join(body), pages)
@@ -0,0 +1,135 @@
1
+ """PR rules as code: each [[rules]] entry in the settings file is a `when` (author kind, paths, size, change risk, labels,
2
+ a first-time author) and a `then` (a label, reviewers, one comment). Matching open pull requests get each action once,
3
+ planned before the data branch's commit and sent after it like the rest of team automation; a dry-run rule only says
4
+ what it matched, and every rule can be replayed on past pull requests to see what it would have done."""
5
+ import re
6
+
7
+ from stackvitals.codeowners import matches as path_matches
8
+ from stackvitals.core.ai_outcomes import mode
9
+ from stackvitals.flow.analyze import is_bot
10
+
11
+ AUTHORS = ("agent", "person", "bot")
12
+ RISKS = ("medium", "high")
13
+ WHEN = {"author", "paths", "lines_over", "risk", "labels", "first_time"}
14
+ THEN = {"label", "reviewers", "comment"}
15
+ LOGIN = re.compile(r"@?[A-Za-z0-9][A-Za-z0-9-]{0,38}(/[A-Za-z0-9._-]{1,100})?")
16
+ MAX_COMMENT = 1000
17
+ MARK = "<!-- stackvitals-rule -->"
18
+
19
+
20
+ def check_rule(rule) -> str | None:
21
+ """What is wrong with one [[rules]] entry, in words, or None when it can be used."""
22
+ if not isinstance(rule, dict) or not isinstance(rule.get("name"), str) or not rule["name"].strip():
23
+ return "each rule needs a name"
24
+ when, then = rule.get("when") or {}, rule.get("then")
25
+ if not isinstance(when, dict) or set(when) - WHEN:
26
+ return f"when takes {', '.join(sorted(WHEN))}"
27
+ if "author" in when and when["author"] not in AUTHORS:
28
+ return f"author is one of {', '.join(AUTHORS)}"
29
+ if "risk" in when and when["risk"] not in RISKS:
30
+ return "risk is medium or high"
31
+ if "paths" in when and not (isinstance(when["paths"], list) and all(isinstance(x, str) and x for x in when["paths"])):
32
+ return "paths is a list of CODEOWNERS-style patterns"
33
+ if "labels" in when and not (isinstance(when["labels"], list) and all(isinstance(x, str) for x in when["labels"])):
34
+ return "labels is a list of label names"
35
+ if "lines_over" in when and not (isinstance(when["lines_over"], int) and when["lines_over"] >= 0):
36
+ return "lines_over is a number of lines"
37
+ if "first_time" in when and not isinstance(when["first_time"], bool):
38
+ return "first_time is true or false"
39
+ if not isinstance(then, dict) or not then or set(then) - THEN:
40
+ return f"then needs one or more of {', '.join(sorted(THEN))}"
41
+ if "label" in then and not (isinstance(then["label"], str) and 0 < len(then["label"]) <= 50):
42
+ return "label is a label name"
43
+ if "reviewers" in then and not (isinstance(then["reviewers"], list) and then["reviewers"]
44
+ and all(isinstance(x, str) and LOGIN.fullmatch(x) for x in then["reviewers"])):
45
+ return "reviewers is a list of logins or @org/team names"
46
+ if "comment" in then and not (isinstance(then["comment"], str) and 0 < len(then["comment"]) <= MAX_COMMENT):
47
+ return f"comment is text up to {MAX_COMMENT} characters"
48
+ if "dry_run" in rule and not isinstance(rule["dry_run"], bool):
49
+ return "dry_run is true or false"
50
+ return None
51
+
52
+
53
+ def _kind(p: dict) -> str:
54
+ if mode(p) == "agent":
55
+ return "agent"
56
+ return "bot" if is_bot(p) else "person"
57
+
58
+
59
+ def matches(p: dict, when: dict, ctx: dict) -> bool:
60
+ if "author" in when and _kind(p) != when["author"]:
61
+ return False
62
+ if "paths" in when and not any(path_matches(pat, path) for path in p.get("paths") or [] for pat in when["paths"]):
63
+ return False
64
+ if "lines_over" in when and (p.get("add") or 0) + (p.get("del") or 0) <= when["lines_over"]:
65
+ return False
66
+ if "risk" in when:
67
+ # "medium" matches medium and high; a PR without a risk score never matches a risk condition.
68
+ level = (ctx.get("risk") or {}).get(p.get("n"))
69
+ if level not in RISKS or RISKS.index(level) < RISKS.index(when["risk"]):
70
+ return False
71
+ if "labels" in when and not set(when["labels"]) & set(p.get("lb") or []):
72
+ return False
73
+ if "first_time" in when and ((p.get("au") or "").lower() in {x.lower() for x in ctx.get("first_time") or ()}) != when["first_time"]:
74
+ return False
75
+ return True
76
+
77
+
78
+ def plan(open_prs: list[dict], rules: list[dict], ctx: dict, state: dict) -> tuple[list[dict], dict, dict]:
79
+ """(writes to make, the state to keep, {rule name: matched PR numbers}); each rule's action on a PR happens once."""
80
+ done = list(state.get("rules_done") or [])
81
+ seen, acts, report = set(done), [], {}
82
+ for rule in rules:
83
+ hit = [p for p in open_prs if not p.get("draft") and matches(p, rule.get("when") or {}, ctx)]
84
+ report[rule["name"]] = [p["n"] for p in hit]
85
+ if rule.get("dry_run"):
86
+ continue
87
+ then = rule["then"]
88
+ for p in hit:
89
+ # Each action once: a label someone removed afterwards stays removed.
90
+ label = {"labels": [then["label"]]} if "label" in then and then["label"] not in (p.get("lb") or []) else None
91
+ for kind, body in (("label", label), ("review", _reviewers(then.get("reviewers"), p)),
92
+ ("comment", _comment(then.get("comment"), rule))):
93
+ key = f"{rule['name']}:{p['n']}:{kind}"
94
+ if body and key not in seen:
95
+ acts.append({"kind": kind, "n": p["n"], "body": body})
96
+ seen.add(key)
97
+ done.append(key)
98
+ return acts, {"rules_done": done[-2000:]}, report
99
+
100
+
101
+ def _reviewers(names, p: dict) -> dict | None:
102
+ if not names:
103
+ return None
104
+ people = [x.lstrip("@") for x in names if "/" not in x and x.lstrip("@").lower() != (p.get("au") or "").lower()]
105
+ teams = [x.split("/", 1)[1] for x in names if "/" in x]
106
+ return {"reviewers": people, "team_reviewers": teams} if people or teams else None
107
+
108
+
109
+ def _comment(text, rule: dict) -> dict | None:
110
+ return {"body": f"{text}\n\n_From the StackVitals rule \"{rule['name']}\"._\n{MARK}"} if text else None
111
+
112
+
113
+ def replay(rules: list[dict], past: list[dict], ctx: dict, examples: int = 10) -> dict:
114
+ """What each rule would have matched among past pull requests (newest last), so a rule is checked before it acts."""
115
+ out = {}
116
+ for rule in rules:
117
+ hit = [p["n"] for p in past if matches(p, rule.get("when") or {}, ctx)]
118
+ out[rule["name"]] = {"matched": len(hit), "of": len(past), "examples": hit[:examples]}
119
+ return out
120
+
121
+
122
+ def context(flow: dict, rules: list[dict]) -> dict:
123
+ """The signals rules can ask about, worked out only when a rule needs them: change risk (shown only where the
124
+ repo's own backtest says it works) and authors with no merged pull request yet."""
125
+ ctx = {}
126
+ wants = {k for r in rules for k in (r.get("when") or {})}
127
+ closed = flow.get("closed") or []
128
+ if "risk" in wants:
129
+ from stackvitals.core import change_risk
130
+ found = change_risk.assess(closed, change_risk.follow_up_fixes(closed), flow.get("open") or [])
131
+ ctx["risk"] = {s["pr"]: s["level"] for s in found.get("open") or []}
132
+ if "first_time" in wants:
133
+ merged = {(p.get("au") or "").lower() for p in closed if p.get("mg")}
134
+ ctx["first_time"] = {p.get("au") for p in flow.get("open") or [] if (p.get("au") or "").lower() not in merged}
135
+ return ctx
@@ -0,0 +1,108 @@
1
+ import gzip
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+
6
+ SCHEMA = "1"
7
+
8
+
9
+ class FlowStore:
10
+ # Closed PRs never change, so they are appended once by close day; open PRs live in one small snapshot.
11
+ def __init__(self, root: Path, folder: str = "flow"):
12
+ # Community keeps its issues the same way, in its own folder.
13
+ self.dir = Path(root) / folder
14
+ self.root = Path(root)
15
+ self.closed = self.dir / "closed"
16
+ self.closed.mkdir(parents=True, exist_ok=True)
17
+ version = self.dir / "VERSION"
18
+ if not version.exists():
19
+ version.write_text(SCHEMA + "\n", encoding="utf-8")
20
+
21
+ def put(self, records: list[dict], day: str) -> None:
22
+ if not records:
23
+ return
24
+ with (self.closed / f"{day}.ndjson").open("a", encoding="utf-8", newline="\n") as f:
25
+ for r in records:
26
+ f.write(json.dumps(r, separators=(",", ":"), ensure_ascii=False) + "\n")
27
+
28
+ def open(self) -> dict[int, dict]:
29
+ try:
30
+ return {r["n"]: r for r in json.loads((self.dir / "open.json").read_text(encoding="utf-8"))}
31
+ except (OSError, ValueError):
32
+ return {}
33
+
34
+ def save_open(self, prs: dict[int, dict]) -> None:
35
+ self._atomic(self.dir / "open.json", json.dumps(sorted(prs.values(), key=lambda r: r["n"]),
36
+ separators=(",", ":"), ensure_ascii=False))
37
+
38
+ def _lines(self):
39
+ for path in sorted(self.closed.iterdir()):
40
+ if path.name.endswith(".ndjson.gz"):
41
+ text = gzip.decompress(path.read_bytes()).decode("utf-8")
42
+ elif path.name.endswith(".ndjson"):
43
+ text = path.read_text(encoding="utf-8")
44
+ else:
45
+ continue
46
+ for line in text.splitlines():
47
+ if line.strip():
48
+ yield json.loads(line)
49
+
50
+ def load(self) -> dict[str, list[dict]]:
51
+ # The newest copy of a closed PR wins; a PR that was reopened counts as open only.
52
+ opened = self.open()
53
+ closed = {}
54
+ for r in self._lines():
55
+ if r["n"] not in closed or r.get("f", "") >= closed[r["n"]].get("f", ""):
56
+ closed[r["n"]] = r
57
+ return {"open": list(opened.values()), "closed": [r for n, r in closed.items() if n not in opened]}
58
+
59
+ def asks(self) -> dict[int, list]:
60
+ # What maintainers asked for per closed PR, as categories only (never the review text).
61
+ from stackvitals.flow.preflight import load_asks
62
+ return load_asks(self.dir / "asks.ndjson")
63
+
64
+ def put_asks(self, asks: dict[int, list]) -> None:
65
+ with (self.dir / "asks.ndjson").open("a", encoding="utf-8", newline="\n") as f:
66
+ for n, a in asks.items():
67
+ f.write(json.dumps({"n": n, "a": a}, separators=(",", ":")) + "\n")
68
+
69
+ def repo(self) -> dict:
70
+ """What the repo query saved, plus deployments and incidents recorded from other tools (repository_dispatch)."""
71
+ from stackvitals.core import recorded
72
+ try:
73
+ info = json.loads((self.dir / "repo.json").read_text(encoding="utf-8"))
74
+ except (OSError, ValueError):
75
+ info = {}
76
+ got = recorded.load(self.root)
77
+ return {**info, "recorded": got} if got["deployments"] or got["incidents"] else info
78
+
79
+ def save_repo(self, info: dict) -> None:
80
+ self._atomic(self.dir / "repo.json", json.dumps(info, indent=1, sort_keys=True))
81
+
82
+ def state(self) -> dict:
83
+ try:
84
+ return json.loads((self.dir / "state.json").read_text(encoding="utf-8"))
85
+ except (OSError, ValueError):
86
+ return {}
87
+
88
+ def save_state(self, state: dict) -> None:
89
+ self._atomic(self.dir / "state.json", json.dumps(state, indent=1, sort_keys=True))
90
+
91
+ def compress(self, today: str) -> None:
92
+ # Past days are gzipped right away, merged with any earlier gz of the same day.
93
+ for path in sorted(self.closed.glob("*.ndjson")):
94
+ day = path.name[:10]
95
+ if day >= today:
96
+ continue
97
+ packed = self.closed / f"{day}.ndjson.gz"
98
+ old = gzip.decompress(packed.read_bytes()) if packed.exists() else b""
99
+ tmp = packed.with_suffix(".tmp")
100
+ tmp.write_bytes(gzip.compress(old + path.read_bytes(), mtime=0))
101
+ os.replace(tmp, packed)
102
+ path.unlink()
103
+
104
+ @staticmethod
105
+ def _atomic(path: Path, text: str) -> None:
106
+ tmp = path.with_suffix(".tmp")
107
+ tmp.write_text(text, encoding="utf-8", newline="\n")
108
+ os.replace(tmp, path)
@@ -0,0 +1,84 @@
1
+ """Code survival: of the lines a squash-merged pull request added, how many are still in the default branch 30 and 90
2
+ days later (git blame, read through GitHub's API), split by people's and agents' PRs. DevLake shows line age per file;
3
+ this asks the question teams adopting agents have: does agent code last?"""
4
+ import json
5
+ import re
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+
9
+ from mergescout.github.graphql import GraphQLError
10
+
11
+ AGES = (30, 90)
12
+ PER_RUN = 12
13
+ FILES = 8
14
+ FILE = "survival.json"
15
+ HEX = re.compile(r"[0-9a-f]{40}")
16
+
17
+
18
+ def _t(s: str) -> datetime:
19
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
20
+
21
+
22
+ def due(prs: list[dict], saved: dict, now: datetime, per_run: int = PER_RUN) -> list[tuple[dict, int]]:
23
+ """(PR, age) still to measure: merged at least `age` days ago (but within 30 days past it), newest first."""
24
+ out = []
25
+ for p in sorted((p for p in prs if p.get("mg") and HEX.fullmatch(p.get("mc") or "") and p.get("add")), key=lambda p: p["mg"], reverse=True):
26
+ days = (now - _t(p["mg"])).days
27
+ for age in AGES:
28
+ if age <= days < age + 30 and str(age) not in (saved.get(str(p["n"])) or {}):
29
+ out.append((p, age))
30
+ return out[:per_run]
31
+
32
+
33
+ def _query(mc: str, paths: list[str]) -> str:
34
+ parts = " ".join(f'f{i}: blame(path: {json.dumps(path)}) {{ ranges {{ startingLine endingLine commit {{ oid }} }} }}'
35
+ for i, path in enumerate(paths))
36
+ return ("query($owner:String!,$name:String!) { rateLimit { cost remaining resetAt } repository(owner:$owner,name:$name) { "
37
+ f'm: object(oid:"{mc}") {{ ... on Commit {{ parents {{ totalCount }} }} }} '
38
+ f'h: defaultBranchRef {{ target {{ ... on Commit {{ {parts} }} }} }} }} }}')
39
+
40
+
41
+ def measure_one(gql, owner: str, name: str, p: dict) -> dict | None:
42
+ """{"kept": lines still blamed on its merge commit, "added": lines it added}; None when it wasn't squash-merged
43
+ (its lines belong to other commits then) or the API refused."""
44
+ paths = [x for x in (p.get("paths") or [])][:FILES]
45
+ if not paths:
46
+ return None
47
+ try:
48
+ repo = gql.query(_query(p["mc"], paths), {"owner": owner, "name": name}).data["repository"]
49
+ except GraphQLError:
50
+ return None
51
+ if ((repo.get("m") or {}).get("parents") or {}).get("totalCount") != 1:
52
+ return None
53
+ target = ((repo.get("h") or {}).get("target")) or {}
54
+ kept = sum(r["endingLine"] - r["startingLine"] + 1 for i in range(len(paths))
55
+ for r in ((target.get(f"f{i}") or {}).get("ranges") or []) if (r.get("commit") or {}).get("oid") == p["mc"])
56
+ return {"kept": kept, "added": p["add"], "files": len(paths), "all_files": len(p.get("paths") or []) <= FILES}
57
+
58
+
59
+ def collect(gql, store_dir: Path, owner: str, name: str, prs: list[dict], now: datetime) -> dict:
60
+ """Measures the PRs due this run and keeps every answer: {pr: {"30": {...}, "90": {...}}}."""
61
+ path = Path(store_dir) / FILE
62
+ try:
63
+ saved = json.loads(path.read_text(encoding="utf-8"))
64
+ except (OSError, ValueError):
65
+ saved = {}
66
+ for p, age in due(prs, saved, now):
67
+ got = measure_one(gql, owner, name, p)
68
+ saved.setdefault(str(p["n"]), {})[str(age)] = got or {"skipped": True}
69
+ path.write_text(json.dumps(saved, sort_keys=True, separators=(",", ":")), encoding="utf-8")
70
+ return saved
71
+
72
+
73
+ def summary(saved: dict, prs: list[dict], kind_of) -> dict | None:
74
+ """Share of added lines still there at each age, for people's PRs and agents' PRs (kind_of(pr) -> "agent" or other)."""
75
+ by_n = {str(p["n"]): p for p in prs}
76
+ out = {}
77
+ for age in AGES:
78
+ for who in ("people", "agents"):
79
+ rows = [v[str(age)] for n, v in saved.items() if str(age) in v and not v[str(age)].get("skipped") and n in by_n
80
+ and v[str(age)].get("all_files") and (kind_of(by_n[n]) == "agent") == (who == "agents")]
81
+ added = sum(r["added"] for r in rows)
82
+ if rows and added:
83
+ out.setdefault(str(age), {})[who] = {"prs": len(rows), "share": round(min(1.0, sum(r["kept"] for r in rows) / added), 4)}
84
+ return out or None
@@ -0,0 +1,62 @@
1
+ """Private triage of open outside PRs for maintainers: the chance of a merge within 30 days and why.
2
+ Computed on demand with the maintainer's own token; never stored, never on a page, never on a PR (user decision D1)."""
3
+ from datetime import datetime, timedelta
4
+
5
+ from mergescout.signals.triage_math import FEATURES, buckets, explain, predict
6
+ from stackvitals.core.actors import People
7
+ from stackvitals.flow.analyze import _iso
8
+
9
+ MODEL_FILE = "triage-model.json"
10
+ NOTE = ("Private: computed on demand for maintainers with your own token; never shown publicly, stored or posted. "
11
+ "It scores the PR's chances, not the person.")
12
+ MIN_REPO_PRS = 10
13
+ MIN_MODEL_PRS = 1000
14
+
15
+
16
+ def _repo_rate(prs: list[dict], opened: str, skip: int | None = None, people: People | None = None) -> float | None:
17
+ # Same as training: outside PRs opened 30 to 395 days before this one, merged within 30 days of opening.
18
+ at = datetime.fromisoformat(opened.replace("Z", "+00:00"))
19
+ lo, hi = _iso(at - timedelta(days=395)), _iso(at - timedelta(days=30))
20
+ if people is None:
21
+ people = People(prs)
22
+ pool = [p for p in prs if p["n"] != skip and people.is_outside(p) and lo <= p["cr"] <= hi]
23
+ if len(pool) < MIN_REPO_PRS:
24
+ return None
25
+ merged = sum(1 for p in pool if p.get("mg") and p["mg"] <= _iso(datetime.fromisoformat(p["cr"].replace("Z", "+00:00"))
26
+ + timedelta(days=30)))
27
+ return merged / len(pool)
28
+
29
+
30
+ def features(target: dict, prs: list[dict], now: datetime, people: People | None = None) -> dict:
31
+ """Only what this repo knew when the PR was opened, and the PR itself (the same rules as training)."""
32
+ au = target.get("au")
33
+ earlier = [p for p in prs if au and p.get("au") == au and p["cr"] < target["cr"]] if au else []
34
+ here = sum(1 for p in earlier if p.get("mg") and p["mg"] < target["cr"])
35
+ # A first contribution is judged from history: GitHub's label is computed when asked, so it isn't reliable.
36
+ return buckets(here, (target.get("add") or 0) + (target.get("del") or 0), bool(target.get("iss")), not earlier,
37
+ _repo_rate(prs, target["cr"], skip=target["n"], people=people))
38
+
39
+
40
+ def valid(model) -> dict | None:
41
+ # Refused: a model fitted on too few PRs (every PR gets about the same chance) or built with other inputs.
42
+ ok = (isinstance(model, dict) and isinstance(model.get("intercept"), (int, float)) and isinstance(model.get("coef"), dict)
43
+ and all(isinstance(v, (int, float)) for v in model["coef"].values()) and (model.get("prs") or 0) >= MIN_MODEL_PRS
44
+ and model.get("features") == FEATURES)
45
+ return model if ok else None
46
+
47
+
48
+ def triage(data: dict, model: dict, now: datetime) -> dict:
49
+ """Score every open, non-draft PR from an outside contributor."""
50
+ prs = data["open"] + data["closed"]
51
+ people = People(prs)
52
+ out = []
53
+ for p in data["open"]:
54
+ if not people.is_outside(p) or p.get("draft"):
55
+ continue
56
+ feats = features(p, prs, now, people)
57
+ out.append({"pr": p["n"], "title": p.get("ti"), "author": p.get("au"), "chance": round(predict(model, feats), 3),
58
+ "reasons": explain(model, feats), "features": feats})
59
+ ev = model.get("evaluation") or {}
60
+ return {"note": NOTE, "prs": sorted(out, key=lambda x: -x["chance"]),
61
+ "model": {"checked_on_prs": ev.get("test_prs"), "brier": ev.get("brier"), "baseline_brier": ev.get("baseline_brier"),
62
+ "built_at": model.get("built_at"), "features": list(FEATURES)}}