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,146 @@
1
+ """Bitbucket Cloud and Azure DevOps pull requests (and Bitbucket Pipelines deployments) in the shapes Delivery reads for
2
+ GitHub, as DevLake's plugins bring them in, with no server: read-only REST, public projects need no token.
3
+
4
+ stackvitals delivery workspace/repo --bitbucket # BITBUCKET_TOKEN for private ones
5
+ stackvitals delivery org/project/repo --azure # AZURE_DEVOPS_TOKEN (a PAT) for private ones
6
+
7
+ Each merged pull request's activity tells when someone else first answered and when it was approved; its merge commit
8
+ links it to the deployment that shipped it. Azure DevOps keeps deployments in release pipelines, outside the Git API:
9
+ send them with `stackvitals record`, or count merges (source = "merges")."""
10
+ import base64
11
+ import os
12
+ from datetime import datetime, timedelta
13
+
14
+ import httpx
15
+
16
+ from stackvitals.ci.api import APIError
17
+
18
+ BITBUCKET = "https://api.bitbucket.org/2.0"
19
+ AZURE = "https://dev.azure.com"
20
+ MAX_PRS = 400
21
+
22
+
23
+ def _iso(s: str | None) -> str | None:
24
+ if not s:
25
+ return None
26
+ t = datetime.fromisoformat(s.replace("Z", "+00:00"))
27
+ return t.strftime("%Y-%m-%dT%H:%M:%SZ")
28
+
29
+
30
+ def _record(n: int, title: str, created: str, merged: str, author: str, base: str, head: str, sha: str | None,
31
+ events: list, talk: list, draft: bool = False) -> dict:
32
+ """One pull request as Team Flow's record (the shape GitHub's and GitLab's take)."""
33
+ return {"t": "pr", "n": n, "ti": (title or "")[:200], "draft": draft, "st": "MERGED", "cr": _iso(created), "up": _iso(merged),
34
+ "mg": _iso(merged), "cl": _iso(merged), "au": author, "at": "User", "as": "MEMBER", "base": base, "head": head,
35
+ "mc": sha, "lb": [], "paths": [], "rq": [], "ev": sorted(events, key=lambda e: e[1] or ""), "cm": talk,
36
+ "fa": None, "nc": None}
37
+
38
+
39
+ class Client:
40
+ """A read-only GET with an optional token, a request budget and plain errors."""
41
+ def __init__(self, base: str, headers: dict | None = None, client: httpx.Client | None = None, budget: int = 900):
42
+ self.base, self.headers, self.budget, self.requests = base.rstrip("/"), headers or {}, budget, 0
43
+ self.client = client or httpx.Client(timeout=60, follow_redirects=True)
44
+
45
+ def get(self, url: str, params: dict | None = None) -> dict:
46
+ if self.requests >= self.budget:
47
+ raise APIError(f"stopped after {self.requests} requests (budget)")
48
+ self.requests += 1
49
+ resp = self.client.get(url if url.startswith("http") else f"{self.base}{url}", params=params, headers=self.headers)
50
+ if resp.status_code >= 400:
51
+ raise APIError(f"GET {url.split('?')[0][-80:]}: HTTP {resp.status_code}")
52
+ return resp.json()
53
+
54
+
55
+ def bitbucket(repo: str, now: datetime, days: int = 90, client: Client | None = None) -> tuple[dict, dict]:
56
+ """(flow, repo facts) for a Bitbucket Cloud repo: merged PRs of the window with their activity, and deployments."""
57
+ tok = os.environ.get("BITBUCKET_TOKEN")
58
+ c = client or Client(BITBUCKET, {"Authorization": f"Bearer {tok}"} if tok else {})
59
+ since = (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S")
60
+ info = c.get(f"/repositories/{repo}")
61
+ closed, url = [], f"/repositories/{repo}/pullrequests"
62
+ params = {"state": "MERGED", "pagelen": 50, "q": f'updated_on > {since}', "sort": "-updated_on"}
63
+ while url and len(closed) < MAX_PRS:
64
+ page = c.get(url, params)
65
+ for p in page.get("values") or []:
66
+ author = (p.get("author") or {}).get("nickname") or (p.get("author") or {}).get("display_name")
67
+ events, talk, merged = [], [], None
68
+ try:
69
+ for a in c.get(f"/repositories/{repo}/pullrequests/{p['id']}/activity", {"pagelen": 50}).get("values") or []:
70
+ if a.get("approval"):
71
+ who = (a["approval"].get("user") or {}).get("nickname")
72
+ events.append(["review", _iso(a["approval"].get("date")), who, "APPROVED", "MEMBER", "User", False])
73
+ elif a.get("comment") and ((a["comment"].get("user") or {}).get("nickname")) != author:
74
+ talk.append([_iso(a["comment"].get("created_on")), (a["comment"].get("user") or {}).get("nickname"), "MEMBER", "User", False])
75
+ elif (a.get("update") or {}).get("state") == "MERGED":
76
+ merged = a["update"].get("date")
77
+ except APIError:
78
+ pass # no activity: the phases end at the merge
79
+ merged = merged or p.get("updated_on")
80
+ events.append(["merged", _iso(merged), (p.get("closed_by") or {}).get("nickname")])
81
+ closed.append(_record(p["id"], p.get("title"), p.get("created_on"), merged, author,
82
+ ((p.get("destination") or {}).get("branch") or {}).get("name"),
83
+ ((p.get("source") or {}).get("branch") or {}).get("name"),
84
+ (p.get("merge_commit") or {}).get("hash"), events, talk, bool(p.get("draft"))))
85
+ url, params = page.get("next"), None
86
+ envs = {}
87
+ try:
88
+ envs = {e["uuid"]: e.get("name") for e in c.get(f"/repositories/{repo}/environments", {"pagelen": 100}).get("values") or []}
89
+ except APIError:
90
+ pass
91
+ deps = []
92
+ try:
93
+ for d in c.get(f"/repositories/{repo}/deployments", {"pagelen": 100, "sort": "-state.started_on"}).get("values") or []:
94
+ state = ((d.get("state") or {}).get("status") or {}).get("name")
95
+ deps.append({"env": envs.get((d.get("environment") or {}).get("uuid")) or "", "at": _iso((d.get("state") or {}).get("started_on")),
96
+ "sha": ((d.get("release") or {}).get("commit") or {}).get("hash"),
97
+ "state": {"SUCCESSFUL": "SUCCESS", "FAILED": "FAILURE"}.get(state),
98
+ "done": _iso((d.get("state") or {}).get("completed_on"))})
99
+ except APIError:
100
+ pass
101
+ facts = {"default_branch": (info.get("mainbranch") or {}).get("name"), "private": bool(info.get("is_private")),
102
+ "deployments": [d for d in deps if d["state"] and d["at"]], "releases": []}
103
+ return {"closed": closed, "open": []}, facts
104
+
105
+
106
+ def azure(path: str, now: datetime, days: int = 90, client: Client | None = None) -> tuple[dict, dict]:
107
+ """(flow, repo facts) for an Azure DevOps repo (org/project/repo): completed PRs of the window with their threads."""
108
+ org, project, repo = path.split("/", 2)
109
+ tok = os.environ.get("AZURE_DEVOPS_TOKEN")
110
+ auth = {"Authorization": "Basic " + base64.b64encode(f":{tok}".encode()).decode()} if tok else {}
111
+ c = client or Client(f"{AZURE}/{org}/{project}/_apis/git", auth)
112
+ since = (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
113
+ info = c.get(f"/repositories/{repo}", {"api-version": "7.1"})
114
+ closed, skip = [], 0
115
+ while len(closed) < MAX_PRS:
116
+ page = c.get(f"/repositories/{repo}/pullrequests", {"searchCriteria.status": "completed", "searchCriteria.minTime": since,
117
+ "searchCriteria.queryTimeRangeType": "closed", "$top": 100, "$skip": skip,
118
+ "api-version": "7.1"}).get("value") or []
119
+ for p in page:
120
+ # Anonymous reads of a public project leave out uniqueName; the display name still tells people apart.
121
+ who_of = lambda x: (x or {}).get("uniqueName") or (x or {}).get("displayName") # noqa: E731
122
+ author = who_of(p.get("createdBy"))
123
+ events, talk = [], []
124
+ try:
125
+ threads = c.get(f"/repositories/{repo}/pullRequests/{p['pullRequestId']}/threads", {"api-version": "7.1"}).get("value") or []
126
+ except APIError:
127
+ threads = []
128
+ for th in threads:
129
+ vote = ((th.get("properties") or {}).get("CodeReviewThreadType") or {}).get("$value") == "VoteUpdate"
130
+ for cm in th.get("comments") or []:
131
+ who, at = who_of(cm.get("author")), _iso(cm.get("publishedDate"))
132
+ if vote and "approved" in (cm.get("content") or "").lower():
133
+ events.append(["review", at, who, "APPROVED", "MEMBER", "User", False])
134
+ elif cm.get("commentType") == "text" and who and who != author:
135
+ talk.append([at, who, "MEMBER", "User", False])
136
+ events.append(["merged", _iso(p.get("closedDate")), who_of(p.get("closedBy"))])
137
+ closed.append(_record(p["pullRequestId"], p.get("title"), p.get("creationDate"), p.get("closedDate"), author,
138
+ (p.get("targetRefName") or "").removeprefix("refs/heads/"),
139
+ (p.get("sourceRefName") or "").removeprefix("refs/heads/"),
140
+ (p.get("lastMergeCommit") or {}).get("commitId"), events, talk, bool(p.get("isDraft"))))
141
+ if len(page) < 100:
142
+ break
143
+ skip += 100
144
+ facts = {"default_branch": (info.get("defaultBranch") or "").removeprefix("refs/heads/") or None,
145
+ "private": (info.get("project") or {}).get("visibility") != "public", "deployments": [], "releases": []}
146
+ return {"closed": closed, "open": []}, facts
@@ -0,0 +1,451 @@
1
+ import re
2
+ from collections import defaultdict
3
+ from datetime import datetime, timedelta
4
+
5
+ from mergescout.github.graphql import GraphQLError, RateLimited, too_heavy
6
+ from stackvitals.flow.store import FlowStore
7
+
8
+ # One page of PRs with their review history costs about 1 point per 25 PRs (measured on poetry and vite).
9
+ QUERY = """
10
+ query($owner:String!,$name:String!,$cursor:String,$n:Int!) {
11
+ rateLimit { cost remaining resetAt }
12
+ repository(owner:$owner,name:$name) {
13
+ pullRequests(first:$n, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}) {
14
+ pageInfo { hasNextPage endCursor }
15
+ nodes {
16
+ number title body isDraft state mergeable createdAt updatedAt mergedAt closedAt additions deletions changedFiles
17
+ author { __typename login } authorAssociation baseRefName headRefName headRepositoryOwner { login }
18
+ files(first:50) { nodes { path } }
19
+ closingIssuesReferences(first:1) { totalCount }
20
+ commits(last:1) { nodes { commit { committedDate authoredDate statusCheckRollup { state } } } }
21
+ first: commits(first:1) { totalCount nodes { commit { authoredDate } } }
22
+ mergeCommit { oid }
23
+ early: timelineItems(first:5, itemTypes:[ISSUE_COMMENT]) {
24
+ nodes { ... on IssueComment { createdAt author { __typename login } authorAssociation } } }
25
+ reviewRequests(first:10) { nodes { requestedReviewer { __typename ... on User { login } ... on Team { slug } } } }
26
+ events: timelineItems(first:60, itemTypes:[READY_FOR_REVIEW_EVENT, CONVERT_TO_DRAFT_EVENT, REVIEW_REQUESTED_EVENT,
27
+ REVIEW_REQUEST_REMOVED_EVENT, PULL_REQUEST_REVIEW, MERGED_EVENT, CLOSED_EVENT, REOPENED_EVENT]) {
28
+ filteredCount
29
+ nodes { __typename
30
+ ... on ReadyForReviewEvent { createdAt }
31
+ ... on ConvertToDraftEvent { createdAt }
32
+ ... on ReviewRequestedEvent { createdAt requestedReviewer { __typename ... on User { login } ... on Team { slug } } }
33
+ ... on ReviewRequestRemovedEvent { createdAt requestedReviewer { __typename ... on User { login } ... on Team { slug } } }
34
+ ... on PullRequestReview { submittedAt state author { __typename login } authorAssociation body comments { totalCount } }
35
+ ... on MergedEvent { createdAt actor { login } }
36
+ ... on ClosedEvent { createdAt actor { login } }
37
+ ... on ReopenedEvent { createdAt }
38
+ }
39
+ }
40
+ labels(first:20) { nodes { name } }
41
+ marks: timelineItems(last:20, itemTypes:[LABELED_EVENT, UNLABELED_EVENT, ASSIGNED_EVENT, UNASSIGNED_EVENT]) {
42
+ nodes { __typename ... on LabeledEvent { createdAt label { name } } ... on UnlabeledEvent { createdAt label { name } }
43
+ ... on AssignedEvent { createdAt assignee { __typename ... on User { login } } }
44
+ ... on UnassignedEvent { createdAt assignee { __typename ... on User { login } } } }
45
+ }
46
+ queue: timelineItems(last:10, itemTypes:[ADDED_TO_MERGE_QUEUE_EVENT, REMOVED_FROM_MERGE_QUEUE_EVENT]) {
47
+ nodes { __typename ... on AddedToMergeQueueEvent { createdAt } ... on RemovedFromMergeQueueEvent { createdAt reason } } }
48
+ recent: commits(last:5) { nodes { commit { authoredDate committedDate parents { totalCount } author { user { login } } message } } }
49
+ talk: timelineItems(last:10, itemTypes:[ISSUE_COMMENT]) {
50
+ filteredCount nodes { ... on IssueComment { createdAt body author { __typename login } authorAssociation } }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }"""
56
+
57
+ # Without checks/statuses read access the built-in token may not read a commit's check result; ask without it then.
58
+ QUERY_NO_CHECKS = QUERY.replace(" statusCheckRollup { state }", "")
59
+ # Once per run: where code ships (releases, deployments), the default branch and CODEOWNERS (1 point).
60
+ REPO_QUERY = """
61
+ query($owner:String!,$name:String!) {
62
+ rateLimit { cost remaining resetAt }
63
+ repository(owner:$owner,name:$name) {
64
+ defaultBranchRef { name }
65
+ primaryLanguage { name }
66
+ stargazerCount
67
+ isPrivate
68
+ releases(first:100, orderBy:{field:CREATED_AT, direction:DESC}) {
69
+ nodes { tagName createdAt publishedAt isPrerelease isDraft tagCommit { oid } } }
70
+ deployments(first:100, orderBy:{field:CREATED_AT, direction:DESC}) {
71
+ nodes { environment createdAt commitOid latestStatus { state createdAt } } pageInfo { hasNextPage endCursor } }
72
+ co1: object(expression:"HEAD:.github/CODEOWNERS") { ... on Blob { text } }
73
+ co2: object(expression:"HEAD:CODEOWNERS") { ... on Blob { text } }
74
+ co3: object(expression:"HEAD:docs/CODEOWNERS") { ... on Blob { text } }
75
+ }
76
+ }"""
77
+ # Deployments past the newest 100 (previews crowd out production on busy repos), one point a page.
78
+ DEPLOY_PAGE = """
79
+ query($owner:String!,$name:String!,$after:String) {
80
+ rateLimit { cost remaining resetAt }
81
+ repository(owner:$owner,name:$name) {
82
+ deployments(first:100, after:$after, orderBy:{field:CREATED_AT, direction:DESC}) {
83
+ nodes { environment createdAt commitOid latestStatus { state createdAt } } pageInfo { hasNextPage endCursor } }
84
+ }
85
+ }"""
86
+ MAX_DEPLOY_PAGES = 40
87
+ # The commits each production deployment shipped: its history back to the deployment before it, 20 per query.
88
+ RANGE_OID = re.compile(r'object\(oid:"([0-9a-z]+)"\)')
89
+ RANGE_BATCH = 20
90
+ # The built-in token reads deployments only with `deployments: read` (every sandbox run failed without it): ask without them.
91
+ REPO_QUERY_NO_DEPLOYMENTS = REPO_QUERY.replace(""" deployments(first:100, orderBy:{field:CREATED_AT, direction:DESC}) {
92
+ nodes { environment createdAt commitOid latestStatus { state createdAt } } pageInfo { hasNextPage endCursor } }
93
+ """, "")
94
+ # Records saved before file lists, linked issues, conflicts, authored dates, review flags, labels, commit authors and assignees
95
+ # were collected are read again once.
96
+ SCHEMA = 8
97
+ OVERLAP = timedelta(hours=1)
98
+ OPEN_ONLY = ("lbe", "cmt", "asg")
99
+ SIMPLE = {"ReadyForReviewEvent": "ready", "ConvertToDraftEvent": "draft", "ClosedEvent": "closed",
100
+ "ReopenedEvent": "reopened"}
101
+
102
+
103
+ def _who(reviewer: dict | None) -> str | None:
104
+ if not reviewer:
105
+ return None
106
+ if reviewer.get("__typename") == "Team":
107
+ return f"team:{reviewer.get('slug')}"
108
+ return reviewer.get("login")
109
+
110
+
111
+ def _event(e: dict, pr_author: str | None = None) -> list | None:
112
+ kind = e.get("__typename")
113
+ if kind == "ClosedEvent":
114
+ # Who closed it: an author closing their own PR is not an answer (Community).
115
+ return ["closed", e.get("createdAt"), (e.get("actor") or {}).get("login")]
116
+ if kind in SIMPLE:
117
+ return [SIMPLE[kind], e.get("createdAt")]
118
+ if kind == "ReviewRequestedEvent":
119
+ return ["req", e.get("createdAt"), _who(e.get("requestedReviewer"))]
120
+ if kind == "ReviewRequestRemovedEvent":
121
+ return ["unreq", e.get("createdAt"), _who(e.get("requestedReviewer"))]
122
+ if kind == "PullRequestReview":
123
+ author = e.get("author") or {}
124
+ # Whether the review raised something (line comments, a question, a mention); its text is never kept.
125
+ raised = bool((e.get("comments") or {}).get("totalCount")) or _asks_author(e.get("body"), pr_author)
126
+ return ["review", e.get("submittedAt"), author.get("login"), e.get("state"), e.get("authorAssociation"),
127
+ author.get("__typename"), raised]
128
+ if kind == "MergedEvent":
129
+ return ["merged", e.get("createdAt"), (e.get("actor") or {}).get("login")]
130
+ return None
131
+
132
+
133
+ # Co-author trailers and footers coding assistants add to commits: what a public repo can show about AI help.
134
+ AI_TRAILERS = (("claude", ("noreply@anthropic.com", "co-authored-by: claude", "generated with [claude code]")),
135
+ ("copilot", ("copilot@users.noreply.github.com", "co-authored-by: copilot")),
136
+ ("cursor", ("cursoragent@cursor.com", "co-authored-by: cursor")),
137
+ ("codex", ("co-authored-by: codex", "codex@openai.com")),
138
+ ("devin", ("devin-ai-integration",)),
139
+ ("aider", ("co-authored-by: aider",)),
140
+ ("gemini", ("co-authored-by: gemini",)))
141
+
142
+
143
+ def ai_tools(messages) -> list[str]:
144
+ text = "\n".join(m or "" for m in messages).lower()
145
+ return sorted(name for name, marks in AI_TRAILERS if any(m in text for m in marks))
146
+
147
+
148
+ # Links a coding agent leaves in the PR description it wrote (a Codex task, a Devin session, a Cursor agent).
149
+ BODY_MARKS = (("codex", ("chatgpt.com/codex/tasks/",)), ("devin", ("app.devin.ai/sessions/",)),
150
+ ("cursor", ("cursor.com/background-agent", "cursor.com/agents")), ("jules", ("jules.google.com/task",)))
151
+
152
+
153
+ def body_tools(body: str | None) -> list[str]:
154
+ """AI tools a PR's description names: the commit trailers and footers, or an agent's task link."""
155
+ text = (body or "").lower()
156
+ return sorted(set(ai_tools([text])) | {name for name, marks in BODY_MARKS if any(m in text for m in marks)})
157
+
158
+
159
+ def _asks_author(body: str | None, author: str | None) -> bool:
160
+ text = body or ""
161
+ return "?" in text or bool(author and f"@{author.lower()}" in text.lower())
162
+
163
+
164
+ def _assignees(marks: list[dict]) -> list[str]:
165
+ # Who is assigned now, replayed from the last assignment events (a separate assignees list costs a point more per page).
166
+ current = []
167
+ for x in sorted((m for m in marks if m and m.get("createdAt")), key=lambda m: m["createdAt"]):
168
+ who = (x.get("assignee") or {}).get("login")
169
+ if who and x.get("__typename") == "AssignedEvent" and who not in current:
170
+ current.append(who)
171
+ elif who and x.get("__typename") == "UnassignedEvent" and who in current:
172
+ current.remove(who)
173
+ return current
174
+
175
+
176
+ def pr_record(node: dict, now: datetime) -> dict:
177
+ """One PR as a compact record: its state, size, author, open review requests and timed events."""
178
+ author = node.get("author") or {}
179
+ last = ((node.get("commits") or {}).get("nodes") or [{}])[-1].get("commit") or {}
180
+ events = [ev for e in (node.get("events") or {}).get("nodes") or [] if e and (ev := _event(e, author.get("login"))) and ev[1]]
181
+ # Comment text is only used to tell whether it asks the author something; it is never kept.
182
+ talk = [[c.get("createdAt"), (c.get("author") or {}).get("login"), c.get("authorAssociation"),
183
+ (c.get("author") or {}).get("__typename"), _asks_author(c.get("body"), author.get("login"))]
184
+ for c in (node.get("talk") or {}).get("nodes") or [] if c]
185
+ # The first comments too: pickup ends at the first answer, which the latest ten miss on busy PRs (no text read).
186
+ seen = {(c[0], c[1]) for c in talk}
187
+ talk += [[c.get("createdAt"), (c.get("author") or {}).get("login"), c.get("authorAssociation"),
188
+ (c.get("author") or {}).get("__typename"), False]
189
+ for c in (node.get("early") or {}).get("nodes") or []
190
+ if c and c.get("createdAt") and (c.get("createdAt"), (c.get("author") or {}).get("login")) not in seen]
191
+ first = node.get("first") or {}
192
+ recent = [x.get("commit") or {} for x in (node.get("recent") or {}).get("nodes") or [] if x]
193
+ return {"t": "pr", "n": node["number"], "ti": (node.get("title") or "")[:200], "draft": node.get("isDraft"), "st": node.get("state"),
194
+ "cr": node.get("createdAt"), "up": node.get("updatedAt"), "mg": node.get("mergedAt"), "cl": node.get("closedAt"),
195
+ "add": node.get("additions"), "del": node.get("deletions"), "files": node.get("changedFiles"),
196
+ "au": author.get("login"), "at": author.get("__typename"), "as": node.get("authorAssociation"),
197
+ "base": node.get("baseRefName"), "head": node.get("headRefName"),
198
+ "ho": (node.get("headRepositoryOwner") or {}).get("login"),
199
+ "lc": last.get("committedDate"), "la": last.get("authoredDate"), "mgs": node.get("mergeable"), "ci": (last.get("statusCheckRollup") or {}).get("state"),
200
+ "iss": bool(((node.get("closingIssuesReferences") or {}).get("totalCount") or 0)),
201
+ "paths": [f["path"] for f in (node.get("files") or {}).get("nodes") or [] if f and f.get("path")],
202
+ "rq": [w for r in (node.get("reviewRequests") or {}).get("nodes") or [] if (w := _who((r or {}).get("requestedReviewer")))],
203
+ "ev": sorted(events, key=lambda e: e[1]), "evn": (node.get("events") or {}).get("filteredCount"),
204
+ # When it entered and left a merge queue, and why it left (merge-queue health); only on PRs that used one.
205
+ **({"mq": queue} if (queue := [["add", q.get("createdAt")] if q.get("__typename") == "AddedToMergeQueueEvent"
206
+ else ["rm", q.get("createdAt"), q.get("reason")]
207
+ for q in (node.get("queue") or {}).get("nodes") or [] if q and q.get("createdAt")]) else {}),
208
+ "cm": sorted((c for c in talk if c[0]), key=lambda c: c[0]),
209
+ "cmn": (node.get("talk") or {}).get("filteredCount"),
210
+ "fa": ((((first.get("nodes") or [{}])[0] or {}).get("commit") or {}).get("authoredDate")),
211
+ "nc": first.get("totalCount"), "mc": (node.get("mergeCommit") or {}).get("oid"),
212
+ # Only the tools named are kept, never the description itself.
213
+ "ai": sorted(set(ai_tools(c.get("message") for c in recent)) | set(body_tools(node.get("body")))),
214
+ "lb": [x["name"] for x in (node.get("labels") or {}).get("nodes") or [] if x and x.get("name")],
215
+ "asg": _assignees((node.get("marks") or {}).get("nodes") or []),
216
+ # When labels were added (+) or removed (-), and who made the last commits (a maintainer's merge of main isn't the author's work).
217
+ "lbe": [[x.get("createdAt"), "+" if x.get("__typename") == "LabeledEvent" else "-", (x.get("label") or {}).get("name")]
218
+ for x in (node.get("marks") or {}).get("nodes") or []
219
+ if x and x.get("createdAt") and x.get("__typename") in ("LabeledEvent", "UnlabeledEvent")],
220
+ "cmt": [[c.get("authoredDate"), c.get("committedDate"), ((c.get("author") or {}).get("user") or {}).get("login"),
221
+ ((c.get("parents") or {}).get("totalCount") or 0) > 1]
222
+ for c in (x.get("commit") or {} for x in (node.get("recent") or {}).get("nodes") or [] if x)],
223
+ "f": now.strftime("%Y-%m-%dT%H:%M:%SZ")}
224
+
225
+
226
+ def collect(gql, store: FlowStore, owner: str, name: str, now: datetime, months: int = 12, page: int = 25,
227
+ max_pages: int = 500, log=None) -> dict:
228
+ """Read PRs updated since the last run (or the last `months` on the first), newest first."""
229
+ state = store.state()
230
+ synced = state.get("synced_up_to") if state.get("schema", 1) >= SCHEMA else None
231
+ floor = (datetime.fromisoformat(synced.replace("Z", "+00:00")) - OVERLAP if synced
232
+ else now - timedelta(days=30 * months)).strftime("%Y-%m-%dT%H:%M:%SZ")
233
+ opened, closed = store.open(), defaultdict(list)
234
+ cursor, newest, count, cost, complete, pages = None, None, 0, 0, True, 0
235
+ query, no_checks = QUERY, False
236
+ while pages < max_pages:
237
+ try:
238
+ result = gql.query(query, {"owner": owner, "name": name, "cursor": cursor, "n": page})
239
+ except RateLimited:
240
+ complete = False
241
+ break
242
+ except GraphQLError as exc:
243
+ if "not accessible" in str(exc).lower() and query is QUERY:
244
+ query, no_checks = QUERY_NO_CHECKS, True
245
+ continue
246
+ # A page GitHub finds too heavy is asked again in smaller pieces.
247
+ if too_heavy(exc) and page > 5:
248
+ page = max(5, page // 2)
249
+ continue
250
+ raise
251
+ pages += 1
252
+ cost += result.cost
253
+ block = result.data["repository"]["pullRequests"]
254
+ stop = False
255
+ for node in block["nodes"]:
256
+ if not node:
257
+ continue
258
+ if node["updatedAt"] < floor:
259
+ stop = True
260
+ break
261
+ newest = newest or node["updatedAt"]
262
+ rec = pr_record(node, now)
263
+ count += 1
264
+ if rec["st"] == "OPEN":
265
+ opened[rec["n"]] = rec
266
+ else:
267
+ opened.pop(rec["n"], None)
268
+ # Labels, label times and commit authors only matter while a PR is open (history stays ~20% smaller).
269
+ closed[(rec["cl"] or rec["up"])[:10]].append({k: v for k, v in rec.items() if k not in OPEN_ONLY})
270
+ if log and pages % 20 == 0:
271
+ log(f"{count} PRs read so far ({cost} points)")
272
+ if stop or not block["pageInfo"]["hasNextPage"]:
273
+ break
274
+ cursor = block["pageInfo"]["endCursor"]
275
+ else:
276
+ complete = False
277
+ for day, recs in closed.items():
278
+ store.put(recs, day)
279
+ store.save_open(opened)
280
+ if complete:
281
+ state["synced_up_to"] = max(filter(None, [newest, synced]), default=None)
282
+ state["schema"] = SCHEMA
283
+ info = repo_info(gql, owner, name, (now - timedelta(days=30 * months)).strftime("%Y-%m-%dT%H:%M:%SZ"), store.repo())
284
+ # A refused repo query keeps the details an earlier run saved.
285
+ if "default_branch" in info:
286
+ store.save_repo(info)
287
+ # Which failing checks are required, and whether CI waits for approval (open PRs only).
288
+ collect_checks(gql, store, owner, name)
289
+ state["last_collect"] = now.strftime("%Y-%m-%dT%H:%M:%SZ")
290
+ store.save_state(state)
291
+ store.compress(now.date().isoformat())
292
+ return {"prs": count, "points": cost, "complete": complete, "no_checks": no_checks,
293
+ "no_deployments": bool(info.get("no_deployments"))}
294
+
295
+
296
+ def repo_info(gql, owner: str, name: str, since: str | None = None, saved: dict | None = None) -> dict:
297
+ """Releases (drafts left out), deployments, the default branch and CODEOWNERS text. Repo details are extras: a
298
+ refused query is asked again without deployments, and if that fails too the run goes on without them."""
299
+ no_deployments = False
300
+ try:
301
+ repo = gql.query(REPO_QUERY, {"owner": owner, "name": name}).data["repository"]
302
+ except GraphQLError:
303
+ no_deployments = True
304
+ try:
305
+ repo = gql.query(REPO_QUERY_NO_DEPLOYMENTS, {"owner": owner, "name": name}).data["repository"]
306
+ except GraphQLError:
307
+ return {"no_deployments": True}
308
+ complete = False
309
+ if not no_deployments and since and repo.get("deployments"):
310
+ more, complete = _older_deployments(gql, owner, name, repo["deployments"], since, saved)
311
+ repo["deployments"]["nodes"] += more
312
+ info = _repo_fields(repo)
313
+ if since and not no_deployments:
314
+ info["deployments"] = merge_history((saved or {}).get("deployments") or [], info["deployments"], since)
315
+ # How far back the saved deployments are complete; a capped backfill goes on next run.
316
+ if complete or ((saved or {}).get("deployments_from") or "9") <= since:
317
+ info["deployments_from"] = since
318
+ if since and not no_deployments:
319
+ info["ranges"] = deploy_ranges(gql, owner, name, info["deployments"], (saved or {}).get("ranges") or {})
320
+ if since:
321
+ info["releases"] = merge_history((saved or {}).get("releases") or [], info["releases"], since, ("tag",))
322
+ return {**info, "no_deployments": True} if no_deployments else info
323
+
324
+
325
+ def _older_deployments(gql, owner: str, name: str, block: dict, since: str, saved: dict | None) -> list[dict]:
326
+ """Pages past the newest 100, back to the window or to two days before what an earlier run saved."""
327
+ known = [d["at"] for d in (saved or {}).get("deployments") or [] if d.get("at")]
328
+ # Saved history counts only once it is complete back to the window (older saves kept just the newest 100).
329
+ complete = known and ((saved or {}).get("deployments_from") or "9") <= since
330
+ stop = max([since] + ([(datetime.fromisoformat(max(known).replace("Z", "+00:00")) - timedelta(days=2))
331
+ .strftime("%Y-%m-%dT%H:%M:%SZ")] if complete else []))
332
+ out, pages = [], 0
333
+ while True:
334
+ oldest = min((n["createdAt"] for n in block.get("nodes") or [] if n and n.get("createdAt")), default="")
335
+ if oldest < stop or not (block.get("pageInfo") or {}).get("hasNextPage"):
336
+ block["complete"] = True
337
+ break
338
+ if pages >= MAX_DEPLOY_PAGES:
339
+ break
340
+ try:
341
+ block = gql.query(DEPLOY_PAGE, {"owner": owner, "name": name, "after": block["pageInfo"]["endCursor"]}
342
+ ).data["repository"]["deployments"]
343
+ except GraphQLError:
344
+ break
345
+ out += [n for n in block.get("nodes") or [] if n and (n.get("createdAt") or "") >= since]
346
+ pages += 1
347
+ return out, bool(block.get("complete"))
348
+
349
+
350
+ def deploy_ranges(gql, owner: str, name: str, deployments: list[dict], saved: dict) -> dict:
351
+ """{"prev..sha": {"shas": [...], "complete": bool}} for each pair of successive production deployments: the
352
+ commits the newer one shipped, newest first (incomplete when the older one is over 100 commits back). Pairs read
353
+ before are kept, never read again."""
354
+ from stackvitals.core.config import DELIVERY
355
+ from stackvitals.core.delivery import is_production
356
+ prod = {x.lower() for x in DELIVERY["production"]}
357
+ seq = [d["sha"] for d in sorted((d for d in deployments if d.get("sha") and RANGE_OID.fullmatch(f'object(oid:"{d["sha"]}")')
358
+ and d.get("state") in ("SUCCESS", "INACTIVE") and is_production(d.get("env"), prod)),
359
+ key=lambda d: d.get("done") or d.get("at") or "")]
360
+ pairs = [(a, b) for a, b in zip(seq, seq[1:]) if a != b]
361
+ out = {f"{a}..{b}": saved[f"{a}..{b}"] for a, b in pairs if f"{a}..{b}" in saved}
362
+ todo = [(a, b) for a, b in pairs if f"{a}..{b}" not in out]
363
+ for i in range(0, len(todo), RANGE_BATCH):
364
+ batch = todo[i:i + RANGE_BATCH]
365
+ parts = " ".join(f'd{k}: object(oid:"{b}") {{ ... on Commit {{ history(first:100) {{ nodes {{ oid }} }} }} }}'
366
+ for k, (_a, b) in enumerate(batch))
367
+ q = ("query($owner:String!,$name:String!) { rateLimit { cost remaining resetAt } "
368
+ f"repository(owner:$owner,name:$name) {{ {parts} }} }}")
369
+ try:
370
+ repo = gql.query(q, {"owner": owner, "name": name}).data["repository"]
371
+ except GraphQLError:
372
+ break
373
+ for k, (a, b) in enumerate(batch):
374
+ nodes = [n["oid"] for n in ((repo.get(f"d{k}") or {}).get("history") or {}).get("nodes") or [] if n]
375
+ shas = []
376
+ for oid in nodes:
377
+ if oid == a:
378
+ break
379
+ shas.append(oid)
380
+ out[f"{a}..{b}"] = {"shas": shas, "complete": a in nodes}
381
+ return out
382
+
383
+
384
+ def merge_history(old: list[dict], new: list[dict], keep_since: str, key=("env", "at", "sha")) -> list[dict]:
385
+ """Earlier runs' deployments (or releases) with this run's, newest first; one read again keeps its new state."""
386
+ seen = {tuple(d.get(k) for k in key): d for d in old if (d.get("at") or "") >= keep_since}
387
+ seen.update({tuple(d.get(k) for k in key): d for d in new if (d.get("at") or "") >= keep_since})
388
+ return sorted(seen.values(), key=lambda d: d.get("at") or "", reverse=True)
389
+
390
+
391
+ def _repo_fields(repo: dict) -> dict:
392
+ codeowners = next((b["text"] for b in (repo.get("co1"), repo.get("co2"), repo.get("co3")) if b and b.get("text")), None)
393
+ return {"default_branch": (repo.get("defaultBranchRef") or {}).get("name"), "codeowners": codeowners,
394
+ "language": (repo.get("primaryLanguage") or {}).get("name"), "stars": repo.get("stargazerCount"),
395
+ "private": bool(repo.get("isPrivate")),
396
+ "releases": [{"tag": r["tagName"], "at": r.get("publishedAt") or r.get("createdAt"), "pre": bool(r.get("isPrerelease")),
397
+ "sha": (r.get("tagCommit") or {}).get("oid")}
398
+ for r in (repo.get("releases") or {}).get("nodes") or [] if r and not r.get("isDraft")],
399
+ "deployments": [{"env": d.get("environment"), "at": d.get("createdAt"), "state": (d.get("latestStatus") or {}).get("state"),
400
+ "sha": d.get("commitOid"), "done": (d.get("latestStatus") or {}).get("createdAt")}
401
+ for d in (repo.get("deployments") or {}).get("nodes") or [] if d]}
402
+
403
+
404
+ # A cancelled run (often a duplicate replaced by a newer one) is not a failure.
405
+ FAILED_STATES = {"FAILURE", "ERROR", "TIMED_OUT", "STARTUP_FAILURE"}
406
+ PASSED_STATES = {"SUCCESS", "NEUTRAL", "SKIPPED"}
407
+
408
+
409
+ def _checks_query(numbers: list[int]) -> str:
410
+ parts = "\n".join(
411
+ f" p{n}: pullRequest(number:{n}) {{ mergeable commits(last:1) {{ nodes {{ commit {{\n"
412
+ " checkSuites(first:15) { nodes { conclusion app { slug } } }\n"
413
+ " statusCheckRollup { contexts(first:60) { nodes { __typename\n"
414
+ f" ... on CheckRun {{ name conclusion isRequired(pullRequestNumber:{n}) }}\n"
415
+ f" ... on StatusContext {{ context state isRequired(pullRequestNumber:{n}) }} }} }} }} }} }} }} }}"
416
+ for n in numbers)
417
+ return (f"query($owner:String!,$name:String!) {{ rateLimit {{ cost remaining resetAt }} "
418
+ f"repository(owner:$owner,name:$name) {{\n{parts}\n}} }}")
419
+
420
+
421
+ def collect_checks(gql, store: FlowStore, owner: str, name: str, batch: int = 20) -> int:
422
+ """For open, ready PRs: which checks fail and whether each is required to merge, and whether CI waits for approval."""
423
+ opened = store.open()
424
+ todo = [n for n, p in opened.items() if not p.get("draft")]
425
+ for i in range(0, len(todo), batch):
426
+ chunk = todo[i:i + batch]
427
+ try:
428
+ repo = gql.query(_checks_query(chunk), {"owner": owner, "name": name}).data["repository"]
429
+ except GraphQLError:
430
+ # Keep the batches already read; the next run reads the rest.
431
+ break
432
+ for n in chunk:
433
+ pull = repo.get(f"p{n}") or {}
434
+ # A push to the base branch can create conflicts without touching the PR, so this is re-read every run.
435
+ if pull.get("mergeable"):
436
+ opened[n]["mgs"] = pull["mergeable"]
437
+ commit = (((pull.get("commits") or {}).get("nodes") or [{}])[-1] or {}).get("commit") or {}
438
+ contexts = ((commit.get("statusCheckRollup") or {}).get("contexts") or {}).get("nodes") or []
439
+ passed = {c.get("name") or c.get("context") for c in contexts if c and (c.get("conclusion") or c.get("state")) in PASSED_STATES}
440
+ # A check that also passed on the same commit (a re-run) is not failing.
441
+ failing = [[c.get("name") or c.get("context"), bool(c.get("isRequired"))] for c in contexts
442
+ if c and (c.get("conclusion") or c.get("state")) in FAILED_STATES
443
+ and (c.get("name") or c.get("context")) not in passed]
444
+ suites = (commit.get("checkSuites") or {}).get("nodes") or []
445
+ opened[n]["chk"] = {"failing": failing, "ran": sorted({c.get("name") or c.get("context") for c in contexts if c} - {None}),
446
+ # Only GitHub Actions runs waiting for approval are the maintainers' move (not a CLA bot).
447
+ "awaiting": any(x and x.get("conclusion") == "ACTION_REQUIRED"
448
+ and (x.get("app") or {}).get("slug") == "github-actions" for x in suites),
449
+ "required_any": any(c and c.get("isRequired") for c in contexts)}
450
+ store.save_open(opened)
451
+ return len(todo)