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,105 @@
1
+ """Opt-in write-back to Jira and Linear (`write_back = true` under the connector, with a key that may write): when a pull
2
+ request naming an issue merges, a comment on the issue links it, and the issue moves to the team's done status when one
3
+ is named. Each pull request and issue once, a few a run; a refusal is said, never fatal."""
4
+ import httpx
5
+
6
+ from stackvitals.connectors.base import ConnectorError
7
+ from stackvitals.connectors.tracker import pr_links
8
+
9
+ MAX_WRITES = 10
10
+ LINEAR = "https://api.linear.app/graphql"
11
+
12
+
13
+ def due(prs: list[dict], issues: list[dict], done: set[str], since: str = "") -> list[tuple[dict, dict]]:
14
+ """(merged PR, issue) pairs merged since write-back was turned on and not written yet, oldest merge first."""
15
+ merged = [p for p in prs if p.get("mg") and p["mg"] >= since and not (p.get("ti") or "").startswith("Revert ")
16
+ and not (p.get("head") or "").startswith("revert-")]
17
+ by_n, by_key = {p["n"]: p for p in merged}, {i["key"]: i for i in issues}
18
+ pairs = [(by_n[n], by_key[key]) for n, key in pr_links(merged, issues) if f"{n}|{key}" not in done]
19
+ return sorted(pairs, key=lambda x: x[0]["mg"])
20
+
21
+
22
+ def _ok(resp: httpx.Response, what: str) -> httpx.Response:
23
+ if resp.status_code >= 300:
24
+ raise ConnectorError(f"{what} was refused ({resp.status_code}); the key needs permission to write.")
25
+ return resp
26
+
27
+
28
+ def jira_comment(client: httpx.Client, auth: str, key: str, text: str) -> None:
29
+ headers = {"Authorization": auth, "Accept": "application/json"}
30
+ doc = {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]}
31
+ _ok(client.post(f"/rest/api/3/issue/{key}/comment", json={"body": doc}, headers=headers), f"A comment on {key}")
32
+
33
+
34
+ def jira_move(client: httpx.Client, auth: str, key: str, transition: str) -> bool:
35
+ """Moves the issue through the named transition when it offers one; False when it doesn't (already done)."""
36
+ if not transition:
37
+ return False
38
+ headers = {"Authorization": auth, "Accept": "application/json"}
39
+ found = _ok(client.get(f"/rest/api/3/issue/{key}/transitions", headers=headers), f"{key}'s transitions").json()
40
+ pick = next((t for t in found.get("transitions") or [] if (t.get("name") or "").lower() == transition.lower()), None)
41
+ if pick:
42
+ _ok(client.post(f"/rest/api/3/issue/{key}/transitions", json={"transition": {"id": pick["id"]}}, headers=headers),
43
+ f"Moving {key} to {transition}")
44
+ return bool(pick)
45
+
46
+
47
+ def _gql(client: httpx.Client, key: str, query: str, variables: dict) -> dict:
48
+ resp = _ok(client.post(LINEAR, json={"query": query, "variables": variables},
49
+ headers={"Authorization": key, "Content-Type": "application/json"}), "Linear")
50
+ body = resp.json()
51
+ if body.get("errors"):
52
+ raise ConnectorError("Linear refused the write; the key needs permission to write.")
53
+ return body.get("data") or {}
54
+
55
+
56
+ def linear_comment(client: httpx.Client, key: str, issue_id: str, text: str) -> None:
57
+ _gql(client, key, "mutation($input: CommentCreateInput!) { ok: commentCreate(input: $input) { success } }",
58
+ {"input": {"issueId": issue_id.split(":", 1)[1], "body": text}})
59
+
60
+
61
+ def linear_move(client: httpx.Client, key: str, issue_id: str, identifier: str, state: str) -> bool:
62
+ """Moves the issue to the team's workflow state of that name; False when the team has none by that name."""
63
+ if not state:
64
+ return False
65
+ found = _gql(client, key, "query($name: String!, $team: String!) { workflowStates(filter: "
66
+ "{name: {eqIgnoreCase: $name}, team: {key: {eq: $team}}}) { nodes { id } } }",
67
+ {"name": state, "team": identifier.split("-", 1)[0]})
68
+ nodes = (found.get("workflowStates") or {}).get("nodes") or []
69
+ if nodes:
70
+ _gql(client, key, "mutation($id: String!, $state: String!) { ok: issueUpdate(id: $id, input: {stateId: $state}) "
71
+ "{ success } }", {"id": issue_id.split(":", 1)[1], "state": nodes[0]["id"]})
72
+ return bool(nodes)
73
+
74
+
75
+ def plan(store, prs: list[dict], now: str) -> list[tuple[dict, dict]]:
76
+ """The pairs to write this run, recorded as written before the data branch is saved, so a retried or failed push
77
+ never sends them twice (at most once, like the rest of the team automation). Merges from before write-back was last
78
+ turned on are left alone, so switching it on never floods old issues."""
79
+ issues = [r for r in store.load().get("issue") or [] if r.get("key")]
80
+ state = store.state()
81
+ since = state.get("write_since") or now
82
+ done = set(state.get("written") or [])
83
+ picked = due(prs, issues, done, since)[:MAX_WRITES]
84
+ done |= {f"{p['n']}|{i['key']}" for p, i in picked}
85
+ store.save_state({**store.state(), "write_since": since, "written": sorted(done)[-2000:]})
86
+ return picked
87
+
88
+
89
+ def send(pairs: list[tuple[dict, dict]], repo: str, comment, move) -> dict:
90
+ """After the commit: one comment per pair, then the move to done; a refused comment stops the run (the key can't
91
+ write), a refused move is said and the next pair goes on."""
92
+ wrote, moved, refused, unmoved = 0, 0, None, []
93
+ for p, issue in pairs:
94
+ text = f"Pull request {repo}#{p['n']} merged on {p['mg'][:10]}: https://github.com/{repo}/pull/{p['n']} (StackVitals)"
95
+ try:
96
+ comment(issue, text)
97
+ except (ConnectorError, httpx.HTTPError) as exc:
98
+ refused = str(exc)
99
+ break
100
+ wrote += 1
101
+ try:
102
+ moved += bool(move(issue))
103
+ except (ConnectorError, httpx.HTTPError) as exc:
104
+ unmoved.append(f"{issue['key']}: {exc}")
105
+ return {"wrote": wrote, "moved": moved, "refused": refused, "unmoved": unmoved}
@@ -0,0 +1 @@
1
+ """Contribute: what it takes to get a change merged in a repo, measured from its history."""
@@ -0,0 +1,297 @@
1
+ """A short brief for coding agents (and people) about how changes get merged in one repo. Only measured facts that an
2
+ agent can't read off the repo's files: per-folder habits of merged PRs, who reviews there, known-flaky CI, and how
3
+ outside PRs fare against similar repos. Every number carries the count it comes from; thin history is said, not guessed.
4
+ Text that contributors control (test names, folder names) is neutralised before it reaches files agents obey."""
5
+ import hashlib
6
+ import os
7
+ import re
8
+ from datetime import datetime, timedelta
9
+ from pathlib import Path
10
+
11
+ from stackvitals import codeowners
12
+ from stackvitals.community.analyze import answered_at
13
+ from stackvitals.core.actors import People, kind_of
14
+ from stackvitals.core.stats import wilson
15
+ from stackvitals.flow import peers
16
+ from stackvitals.flow.analyze import _iso, _median, is_bot, stages
17
+ from stackvitals.flow.knowledge import _areas, expertise
18
+ from stackvitals.flow.preflight import CHANGELOG_FILE, HABIT, Habits, preflight
19
+
20
+ MIN_AREA_PRS = 10
21
+ ASK_MIN = 0.1 # an ask seen on fewer PRs than this is not a habit
22
+ MAX_AREAS = 6
23
+ LOW_SAMPLE = 30
24
+ SIZES = (("under 150 lines", 0, 150), ("150-500 lines", 150, 500), ("over 500 lines", 500, None))
25
+ LABELS = {"tests": "tests", "changelog": "a changelog entry", "docs": "docs"}
26
+ START, END = "<!-- stackvitals:brief -->", "<!-- /stackvitals:brief -->"
27
+ STAMP = "Written by StackVitals"
28
+ SAFE_PART = re.compile(r"^[A-Za-z0-9_@+-][A-Za-z0-9._@+-]*$")
29
+
30
+
31
+ class BriefError(RuntimeError):
32
+ pass
33
+
34
+
35
+ def _clean(text, limit: int = 100) -> str:
36
+ # Contributor-controlled text stays one plain line: no newlines, comment markers or code-format breaks.
37
+ s = re.sub(r"[\x00-\x1f\x7f]+", " ", str(text)).replace("<!--", "").replace("-->", "").replace("`", "'").strip()
38
+ return s if len(s) <= limit else s[:limit - 1] + "…"
39
+
40
+
41
+ def _code(text) -> str:
42
+ return f"`{_clean(text)}`"
43
+
44
+
45
+ def _safe_area(name: str) -> bool:
46
+ return name != "(root)" and all(SAFE_PART.match(part) for part in name.split("/"))
47
+
48
+
49
+ def _is_agent(p: dict) -> bool:
50
+ # The crawl's own rule: agent accounts, or plain names (codex, claude) only when the account is a bot.
51
+ return kind_of(p.get("au"), p.get("at")) == "agent"
52
+
53
+
54
+ def _hours(a: str, b: str) -> float | None:
55
+ da, db = (datetime.fromisoformat(x.replace("Z", "+00:00")) for x in (a, b))
56
+ return (db - da).total_seconds() / 3600 if db >= da else None
57
+
58
+
59
+ def _lines(p: dict) -> int | None:
60
+ if p.get("add") is None and p.get("del") is None:
61
+ return None
62
+ return (p.get("add") or 0) + (p.get("del") or 0)
63
+
64
+
65
+ def _outside(prs: list[dict], since: str, now: datetime, bench, language, stars) -> dict:
66
+ # Maintainers count by what they do too (a lead shown as CONTRIBUTOR who merges PRs is not an outsider).
67
+ people = People(prs)
68
+ ins = people.insiders()
69
+ outside = [p for p in prs if people.is_outside(p)]
70
+ closed = [p for p in outside if p.get("st") in ("MERGED", "CLOSED") and (p.get("cl") or "") >= since]
71
+ merged = sum(p["st"] == "MERGED" for p in closed)
72
+ n = len(closed)
73
+ out = {"closed": n, "merged": merged, "rate": round(merged / n, 4) if n else None, "low_sample": n < LOW_SAMPLE}
74
+ if n:
75
+ lo, hi = wilson(merged, n)
76
+ out.update(low=round(lo, 4), high=round(hi, 4))
77
+ out["by_size"] = []
78
+ sized = [(p, x) for p in closed if (x := _lines(p)) is not None]
79
+ for label, lo_lines, hi_lines in SIZES:
80
+ group = [p for p, x in sized if x >= lo_lines and (hi_lines is None or x < hi_lines)]
81
+ if len(group) >= 5:
82
+ out["by_size"].append({"size": label, "closed": len(group),
83
+ "rate": round(sum(p["st"] == "MERGED" for p in group) / len(group), 4)})
84
+ # A maintainer's first answer (review, comment or close by a maintainer) to outside PRs at least a day old.
85
+ day_old = _iso(now - timedelta(days=1))
86
+ asked = [p for p in outside if since <= p["cr"] < day_old]
87
+ waits = [w for p in asked if (a := answered_at(p, ins)) and (w := _hours(p["cr"], a)) is not None]
88
+ out.update(asked=len(asked), answered=len(waits),
89
+ first_answer_hours=round(_median(waits), 1) if len(waits) >= 5 else None)
90
+ found = peers.compare({"outsider_merge_rate": out["rate"]}, bench, language, stars) if n else None
91
+ if found:
92
+ m = found["metrics"][0]
93
+ out["peers"] = {"group": found["group"], "repos": m["repos"], "place": m["place"], "typical": m["typical"]}
94
+ return out
95
+
96
+
97
+ def _group(bench: dict | None, section: str, language: str | None, stars: int | None) -> tuple[str, dict] | None:
98
+ from mergescout.bench.ci import tier
99
+ from stackvitals.ci.bench import _label
100
+ groups = (bench or {}).get(section) or {}
101
+ size = tier(stars)
102
+ key = next((k for k in (f"{language}|{size}", f"{language}|all", f"all|{size}", "all|all") if k in groups), None)
103
+ return (_label(key), groups[key]) if key else None
104
+
105
+
106
+ def _agents(prs: list[dict], since: str) -> dict | None:
107
+ mine = [p for p in prs if _is_agent(p) and p.get("st") in ("MERGED", "CLOSED") and (p.get("cl") or "") >= since]
108
+ if len(mine) < 10:
109
+ return None
110
+ merged = sum(p["st"] == "MERGED" for p in mine)
111
+ return {"closed": len(mine), "merged": merged, "rate": round(merged / len(mine), 4)}
112
+
113
+
114
+ def _areas_brief(prs: list[dict], asks: dict, rules, now: datetime, months: int) -> list[dict]:
115
+ habits = Habits(prs, now, months)
116
+ ex = expertise(prs, now)
117
+ since = _iso(now - timedelta(days=30 * months))
118
+ merged = [p for p in prs if p.get("mg") and p["mg"] >= since and not is_bot(p)]
119
+ out = []
120
+ for name, idx in sorted(habits.by_area.items(), key=lambda kv: -len(kv[1])):
121
+ if len(idx) < MIN_AREA_PRS or len(out) >= MAX_AREAS:
122
+ break
123
+ if CHANGELOG_FILE.search(f"{name}/_"):
124
+ continue # a changelog-fragment folder (news/, changelog.d/): said once for the whole repo instead
125
+ probe = "_" if name == "(root)" else f"{name}/_"
126
+ norms = habits.norms([probe])
127
+ pickups = [s["pickup"] / 3600 for p in merged if name in _areas(p.get("paths")) and (s := stages(p))["pickup"] is not None]
128
+ asked = preflight(prs, asks, [probe], now, months)
129
+ reviewers = sorted(ex.reviewed.get(name, {}).items(), key=lambda kv: -kv[1])
130
+ out.append({"area": name, "merged_prs": norms["merged_prs"], "include": norms["include"], "size": norms["size"],
131
+ "asks": [x for x in asked["asks"] if x["share"] >= ASK_MIN and x["prs"] >= 3][:2]
132
+ if asked["scope"] != "whole repo" else [], "reviewed_prs": asked["reviewed_prs"],
133
+ "reviewers": [{"login": who, "reviews": c} for who, c in reviewers[:3] if c >= 3],
134
+ # A folder's review speed only when two or more people review there, so it is never one person's.
135
+ "first_review_hours": round(_median(pickups), 1) if len(pickups) >= 5 and len(reviewers) >= 2 else None,
136
+ "owners": codeowners.owners_for(probe, rules) if name != "(root)" and rules else []})
137
+ return out
138
+
139
+
140
+ def _flaky(ci: dict | None) -> list[dict]:
141
+ def ok(i):
142
+ return i.get("state") == "flaky" and isinstance(i.get("flake_events"), int) and isinstance(i.get("runs"), int)
143
+ items = [{"name": f"{(j.get('workflow') or '?').rsplit('/', 1)[-1]} / {j.get('job')}", "kind": "job",
144
+ "flakes": j["flake_events"], "runs": j["runs"]} for j in (ci or {}).get("jobs") or [] if ok(j) and j.get("job")]
145
+ items += [{"name": t["id"], "kind": "test", "flakes": t["flake_events"], "runs": t["runs"]}
146
+ for t in (ci or {}).get("tests") or [] if ok(t) and t.get("id")]
147
+ return sorted(items, key=lambda i: (i["kind"] != "job", -i["flakes"]))[:5]
148
+
149
+
150
+ def build_brief(repo: str, data: dict, repo_info: dict, asks: dict, ci: dict | None, bench: dict | None, now: datetime,
151
+ months: int = 12) -> dict:
152
+ prs = (data.get("open") or []) + (data.get("closed") or [])
153
+ since = _iso(now - timedelta(days=30 * months))
154
+ people = [p for p in prs if not is_bot(p) and (p.get("cr") or "") >= since]
155
+ rules = codeowners.parse(repo_info.get("codeowners")) if repo_info.get("codeowners") else []
156
+ outside = _outside(prs, since, now, bench, repo_info.get("language"), repo_info.get("stars"))
157
+ # A repo's past merge rate runs high for its next PRs (many stay open); the crawl's backtest says by how much.
158
+ bt = (bench or {}).get("odds_backtest") or {}
159
+ if outside["rate"] is not None and outside["closed"] >= 5:
160
+ cell = next((c for c in bt.get("calibration_raw") or [] if c["bin"] == f"{min(int(outside['rate'] * 10), 9) / 10:.1f}-"
161
+ f"{min(int(outside['rate'] * 10), 9) / 10 + 0.1:.1f}"), None)
162
+ if cell:
163
+ outside["estimate"] = cell["observed"]
164
+ check = {"repos": bt.get("repos"), "mae": (bt.get("mae") or {}).get("calibrated")}
165
+ return {"repo": repo, "generated": now.strftime("%Y-%m-%d"), "months": months, "prs": len(people),
166
+ # The history actually read: a store first filled for 3 months is said as such, not as 12.
167
+ "since": min((p["cr"] for p in people), default=now.strftime("%Y-%m-%d"))[:10],
168
+ "outside": outside, "agents": _agents(prs, since), "repo_habits": Habits(prs, now, months).norms([]),
169
+ "agents_nearby": _group(bench, "agent_prs", repo_info.get("language"), repo_info.get("stars")),
170
+ "backtest": check if check.get("mae") is not None else None,
171
+ "areas": _areas_brief(prs, asks or {}, rules, now, months), "flaky": _flaky(ci)}
172
+
173
+
174
+ def _h(hours) -> str:
175
+ if hours is None:
176
+ return "-"
177
+ return f"{hours:.0f} h" if hours < 48 else f"{hours / 24:.1f} days"
178
+
179
+
180
+ def _area_lines(a: dict) -> list[str]:
181
+ habits = [f"{LABELS[k]} ({v:.0%})" for k, v in a["include"].items() if v >= HABIT]
182
+ size = a["size"]
183
+ lines = [("Merged PRs here include " + ", ".join(habits) + "; " if habits else "Merged PRs here: ")
184
+ + f"median {size['median_lines']} lines, 75% under {size['p75_lines']}."]
185
+ if a["asks"]:
186
+ lines.append("Maintainers most often ask for: " + ", ".join(
187
+ f"{_clean(x['ask'])} ({x['share']:.0%} of {a['reviewed_prs']} PRs with maintainer comments)" for x in a["asks"]) + ".")
188
+ who = ", ".join(f"@{_clean(r['login'], 40)} ({r['reviews']} reviews)" for r in a["reviewers"])
189
+ parts = ([f"Reviewed most by {who}"] if who else []) + (
190
+ [f"first review: median {_h(a['first_review_hours'])}"] if a["first_review_hours"] is not None else [])
191
+ if a["owners"]:
192
+ parts.append("CODEOWNERS: " + " ".join(_clean(o, 60) for o in a["owners"]))
193
+ if parts:
194
+ lines.append("; ".join(parts) + ".")
195
+ return lines
196
+
197
+
198
+ def render(b: dict) -> str:
199
+ """The brief as Markdown, at most about 40 lines."""
200
+ o = b["outside"]
201
+ out = [f"## How changes get merged in {_clean(b['repo'])}", "",
202
+ f"Measured by StackVitals from {b['prs']} pull requests since {b['since']} ({b['generated']}); "
203
+ "each number says how many PRs it comes from. Bots are left out.", ""]
204
+ if o["closed"] >= 5:
205
+ line = f"- **Outside contributors:** {o['merged']} of {o['closed']} closed PRs merged ({o['rate']:.0%}, likely {o['low']:.0%}-{o['high']:.0%})"
206
+ if o.get("peers"):
207
+ line += f"; {o['peers']['place']} of {o['peers']['group']} (typical {o['peers']['typical']:.0%})"
208
+ line += "."
209
+ if o["by_size"]:
210
+ line += " By size: " + ", ".join(f"{s['size']} {s['rate']:.0%} (n={s['closed']})" for s in o["by_size"]) + "."
211
+ if o["first_answer_hours"] is not None:
212
+ line += f" First maintainer answer: median {_h(o['first_answer_hours'])} for the {o['answered']} answered"
213
+ line += (f"; {o['asked'] - o['answered']} of {o['asked']} never answered." if o["asked"] > o["answered"] else ".")
214
+ if o["low_sample"]:
215
+ line += " Few outside PRs, so treat these as rough."
216
+ out.append(line)
217
+ if o.get("estimate") is not None:
218
+ check = b.get("backtest")
219
+ out.append(f" Of new outside PRs here, about {o['estimate']:.0%} get merged within 3 months (this repo's record, "
220
+ "corrected by what happened next on other repos with a similar record, since many new PRs stay open"
221
+ + (f"; checked on {check['repos']} repos, off by {check['mae'] * 100:.0f} points on average" if check else "")
222
+ + ").")
223
+ r = b.get("repo_habits") or {}
224
+ habits = [f"{LABELS[k]} ({v:.0%})" for k, v in (r.get("include") or {}).items() if v >= HABIT]
225
+ if r.get("merged_prs", 0) >= MIN_AREA_PRS and habits:
226
+ out.append(f"- **Across the repo:** merged PRs include {', '.join(habits)}; median {r['size']['median_lines']} lines "
227
+ f"(n={r['merged_prs']}).")
228
+ if b.get("agents"):
229
+ a = b["agents"]
230
+ out.append(f"- **PRs opened by coding agents here:** {a['merged']} of {a['closed']} merged ({a['rate']:.0%}).")
231
+ if b.get("agents_nearby"):
232
+ label, g = b["agents_nearby"]
233
+ people = f" vs {g['outsider']['merged']:.0%} for people's outside PRs" if g["outsider"].get("merged") is not None else ""
234
+ out.append(f"- **PRs opened by coding agents in {label}** (often started by the repos' own maintainers): the typical "
235
+ f"repo merges {g['agent']['merged']:.0%} of them{people} ({g['repos_with_agent_prs']} repos, "
236
+ f"n={g['agent']['decided']}).")
237
+ if b["flaky"]:
238
+ out.append("- **Known flaky CI (if one of these fails, check the error is not from your change, then re-run):** " + "; ".join(
239
+ f"{_code(f['name'])} {f['flakes']} of {f['runs']} runs" for f in b["flaky"]) + ".")
240
+ for a in b["areas"]:
241
+ label = "files at the repo root" if a["area"] == "(root)" else _code(a["area"])
242
+ out += ["", f"### {label} ({a['merged_prs']} merged PRs)"] + [f"- {x}" for x in _area_lines(a)]
243
+ if not b["areas"] and o["closed"] < 5:
244
+ out += ["", "Not enough history here yet to say how changes usually get merged."]
245
+ return "\n".join(out) + "\n"
246
+
247
+
248
+ def _write(path: Path, text: str) -> None:
249
+ # Written whole or not at all: an interrupted run never leaves half a file.
250
+ tmp = path.with_name(path.name + ".tmp")
251
+ tmp.write_text(text, encoding="utf-8")
252
+ os.replace(tmp, path)
253
+
254
+
255
+ def _agents_md(old: str, section: str) -> str:
256
+ """The file with our section replaced (or added at the end); markers count only as whole lines. Anything else
257
+ (one marker, reversed, two sections) is refused rather than guessed, so a maintainer's text is never lost."""
258
+ lines = old.splitlines(keepends=True)
259
+ starts = [i for i, line in enumerate(lines) if line.strip() == START]
260
+ ends = [i for i, line in enumerate(lines) if line.strip() == END]
261
+ if not starts and not ends:
262
+ return (old.rstrip("\n") + "\n\n" if old.strip() else "") + section
263
+ if len(starts) == 1 and len(ends) == 1 and starts[0] < ends[0]:
264
+ return "".join(lines[:starts[0]]) + section + "".join(lines[ends[0] + 1:])
265
+ raise BriefError("AGENTS.md has StackVitals markers that don't pair up (one start line and one end line after it); "
266
+ "fix or remove them by hand, then run again.")
267
+
268
+
269
+ def write_files(b: dict, root: Path) -> list[str]:
270
+ """Opt-in: the brief as a marked section of AGENTS.md (the rest of the file kept) and one Copilot path-instructions
271
+ file per folder. Only files written by StackVitals are replaced or removed."""
272
+ root = Path(root)
273
+ agents = root / "AGENTS.md"
274
+ old = agents.read_text(encoding="utf-8") if agents.exists() else ""
275
+ _write(agents, _agents_md(old, f"{START}\n{render(b)}{END}\n"))
276
+ written = ["AGENTS.md"]
277
+ folder = root / ".github" / "instructions"
278
+ keep = set()
279
+ for a in b["areas"]:
280
+ if not _safe_area(a["area"]):
281
+ continue # odd folder names never reach a file name or applyTo
282
+ slug = re.sub(r"[^a-z0-9]+", "-", a["area"].lower()).strip("-")
283
+ name = f"stackvitals-{slug}-{hashlib.sha1(a['area'].encode('utf-8')).hexdigest()[:6]}.instructions.md"
284
+ keep.add(name)
285
+ # A two-level area ("src/db") is everything under it; a one-level one ("src") only its own files.
286
+ apply_to = f"{a['area']}/**" if "/" in a["area"] else f"{a['area']}/*"
287
+ folder.mkdir(parents=True, exist_ok=True)
288
+ body = "\n".join(f"- {x}" for x in _area_lines(a))
289
+ _write(folder / name, f'---\napplyTo: "{apply_to}"\n---\n<!-- {STAMP} from {_clean(b["repo"])}\'s merged PRs '
290
+ f"({b['generated']}); run `stackvitals contribute brief` again to refresh. -->\n"
291
+ f"How merged PRs in {a['area']} look ({a['merged_prs']} merged PRs):\n{body}\n")
292
+ written.append(f".github/instructions/{name}")
293
+ if folder.is_dir():
294
+ for old_file in folder.glob("stackvitals-*.instructions.md"):
295
+ if old_file.name not in keep and STAMP in old_file.read_text(encoding="utf-8", errors="replace"):
296
+ old_file.unlink()
297
+ return written
@@ -0,0 +1,64 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+
9
+ from stackvitals.ci import cli as ci_cli
10
+ from stackvitals.ci.api import ActionsAPI, APIError, OutOfBudget
11
+ from stackvitals.contribute.brief import BriefError, build_brief, render, write_files
12
+ from stackvitals.core import services
13
+ from stackvitals.flow import peers
14
+ from stackvitals.flow.cli import local_gql
15
+ from stackvitals.flow.collect import collect as flow_collect
16
+ from stackvitals.flow.preflight import collect_asks
17
+ from stackvitals.flow.store import FlowStore
18
+
19
+
20
+ def ci_history(tok: str, repo: str, home: Path, now: datetime, days: int, make_api=ActionsAPI) -> tuple[dict | None, str]:
21
+ """CI Health's findings: the repo's own published file when it runs StackVitals, else a short live read (the same
22
+ reader as the MCP server and the CI CLI)."""
23
+ try:
24
+ result, source = services.ci_result(repo, days, tok, home, make_api=make_api, now=now)
25
+ except (APIError, OutOfBudget, httpx.HTTPError) as exc:
26
+ return None, f"CI history not read ({exc})"
27
+ return result, "the repo's stackvitals-data branch" if source == "stackvitals-data branch" else source
28
+
29
+
30
+ def gather(repo: str, tok: str, now: datetime, months: int, ci_days: int, make_gql=local_gql, make_api=ActionsAPI):
31
+ home = ci_cli.home()
32
+ owner, name = repo.split("/", 1)
33
+ store = FlowStore(home / "flow" / repo.replace("/", "__"))
34
+ gql = make_gql(tok)
35
+ flow_collect(gql, store, owner, name, now, months=months, log=lambda line: print(line, file=sys.stderr, flush=True))
36
+ collect_asks(gql, store, owner, name, store.load()["closed"], cap=1000)
37
+ ci, ci_source = ci_history(tok, repo, home, now, ci_days, make_api) if ci_days else (None, "CI not read")
38
+ return store.load(), store.repo(), store.asks(), ci, peers.load(tok, home / "bench"), ci_source
39
+
40
+
41
+ def main(argv: list[str] | None = None, now: datetime | None = None, tok: str | None = None, gather_fn=gather) -> int:
42
+ parser = argparse.ArgumentParser(prog="stackvitals contribute brief",
43
+ description="How changes get merged in a repo, measured from its history (for agents and people).")
44
+ parser.add_argument("repo", help="owner/name")
45
+ parser.add_argument("--months", type=int, default=12, help="pull request history to read")
46
+ parser.add_argument("--ci-days", type=int, default=14, help="days of CI runs to read when the repo doesn't run StackVitals (0: skip)")
47
+ parser.add_argument("--json", action="store_true")
48
+ parser.add_argument("--write", metavar="DIR", help="also write an AGENTS.md section and Copilot path files into this folder")
49
+ args = parser.parse_args(argv)
50
+ tok = tok or ci_cli.token()
51
+ if not tok:
52
+ print("A GitHub token is needed. Set GITHUB_TOKEN, or run `gh auth login`.", file=sys.stderr)
53
+ return 1
54
+ now = now or datetime.now(timezone.utc)
55
+ data, repo_info, asks, ci, bench, ci_source = gather_fn(args.repo, tok, now, args.months, args.ci_days)
56
+ brief = build_brief(args.repo, data, repo_info, asks, ci, bench, now, months=args.months)
57
+ if args.write:
58
+ try:
59
+ for path in write_files(brief, Path(args.write)):
60
+ print(f"wrote {path}", file=sys.stderr)
61
+ except BriefError as exc:
62
+ print(f"Nothing written: {exc}", file=sys.stderr)
63
+ print(json.dumps({**brief, "ci_source": ci_source}, indent=1) if args.json else render(brief))
64
+ return 0
@@ -0,0 +1 @@
1
+ """Shared parts of the suite: one model, one role rule, one metric registry, one set of stats and time helpers."""
@@ -0,0 +1,103 @@
1
+ """Who is a person, a bot or a coding agent, and who is an insider: one rule for every module, the crawl's rule
2
+ (`mergescout.signals.identity`), so a repo's own numbers and its peer benchmarks mean the same thing."""
3
+ import re
4
+ from collections import defaultdict
5
+ from dataclasses import dataclass
6
+
7
+ from mergescout.signals.rules import (AI_AGENT_BOT_NAMES, AI_AGENT_LOGINS, BOT_NAME_PATTERN, HUMAN_BOT_NAMES,
8
+ KNOWN_BOTS, UNKNOWN_LOGINS)
9
+
10
+ INSIDER_ASSOC = frozenset({"OWNER", "MEMBER", "COLLABORATOR"})
11
+ _BOT_NAME = re.compile(BOT_NAME_PATTERN)
12
+ _HUMAN_NAME = re.compile(HUMAN_BOT_NAMES)
13
+ # The crawl's reason order, so both sides print the same text.
14
+ REASONS = ("bot account", "listed role", "merges others", "merges own", "in owner files")
15
+
16
+
17
+ def kind_of(login: str | None, typ: str | None = None) -> str:
18
+ """person, bot, agent or unknown, from the login and GitHub's account type when known."""
19
+ if not login or login.lower() in UNKNOWN_LOGINS:
20
+ return "unknown"
21
+ if typ == "Person": # set by a repo's identity settings, so it beats the name rules
22
+ return "person"
23
+ low = login.lower()
24
+ base = low.removesuffix("[bot]")
25
+ bot = typ == "Bot" or low.endswith("[bot]") or low in KNOWN_BOTS or bool(_BOT_NAME.search(low) and not _HUMAN_NAME.search(low))
26
+ if base in AI_AGENT_LOGINS or (base in AI_AGENT_BOT_NAMES and bot):
27
+ return "agent"
28
+ return "bot" if bot else "person"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Actor:
33
+ login: str
34
+ kind: str # person, bot, agent, unknown
35
+ role: str # insider, outsider, or the kind again for non-people
36
+ reasons: tuple[str, ...]
37
+
38
+
39
+ class People:
40
+ """Insiders are found by what they do: a listed role anywhere, merging anyone's PR, or a name in the owner files."""
41
+
42
+ def __init__(self, prs: list[dict], issues: list[dict] = (), owner_logins: list[str] = ()):
43
+ self._typ: dict[str, str | None] = {}
44
+ self._name: dict[str, str] = {}
45
+ self._flags: dict[str, set[str]] = defaultdict(set)
46
+ for r in list(prs) + list(issues):
47
+ au = r.get("au")
48
+ self._see(au, r.get("at"), r.get("as"))
49
+ for e in r.get("ev") or []:
50
+ if e[0] == "review" and len(e) > 5:
51
+ self._see(e[2], e[5], e[4])
52
+ elif e[0] == "merged" and len(e) > 2 and e[2]:
53
+ self._see(e[2], None, None)
54
+ self._flags[e[2].lower()].add("merges own" if e[2] == au else "merges others")
55
+ for c in r.get("cm") or []:
56
+ if len(c) > 3:
57
+ self._see(c[1], c[3], c[2])
58
+ for login in owner_logins:
59
+ if login:
60
+ self._see(login, None, None)
61
+ self._flags[login.lower()].add("in owner files")
62
+
63
+ def _see(self, login: str | None, typ: str | None, assoc: str | None) -> None:
64
+ if not login:
65
+ return
66
+ low = login.lower()
67
+ self._name.setdefault(low, login)
68
+ # Once seen as a bot account, always a bot.
69
+ if typ == "Bot" or self._typ.get(low) is None:
70
+ self._typ[low] = typ or self._typ.get(low)
71
+ if assoc in INSIDER_ASSOC:
72
+ self._flags[low].add("listed role")
73
+
74
+ def kind(self, login: str | None) -> str:
75
+ return kind_of(login, self._typ.get((login or "").lower()))
76
+
77
+ def role(self, login: str | None) -> str:
78
+ kind = self.kind(login)
79
+ if kind != "person":
80
+ return kind
81
+ return "insider" if self._flags.get(login.lower()) else "outsider"
82
+
83
+ def reasons(self, login: str | None) -> str:
84
+ if not login:
85
+ return ""
86
+ found = self._flags.get(login.lower(), set())
87
+ if self.kind(login) in ("bot", "agent"):
88
+ found = found | {"bot account"}
89
+ return ", ".join(r for r in REASONS if r in found)
90
+
91
+ def actor(self, login: str | None) -> Actor:
92
+ return Actor(login or "", self.kind(login), self.role(login), tuple(r for r in REASONS if r in self._flags.get((login or "").lower(), ())))
93
+
94
+ def is_insider(self, login: str | None) -> bool:
95
+ return self.role(login) == "insider"
96
+
97
+ def insiders(self) -> set[str]:
98
+ return {self._name[low] for low in self._flags if self.role(self._name[low]) == "insider"}
99
+
100
+ def is_outside(self, rec: dict) -> bool:
101
+ """A person who is not an insider wrote it: never a bot, an agent, a ghost or a maintainer."""
102
+ au = rec.get("au")
103
+ return kind_of(au, rec.get("at")) == "person" and self.role(au) == "outsider"