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,286 @@
1
+ import hashlib
2
+ import os
3
+ import sqlite3
4
+ import threading
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timedelta, timezone
9
+ from pathlib import Path
10
+
11
+ import pyarrow as pa
12
+ import pyarrow.parquet as pq
13
+
14
+ from mergescout.github.graphql import GraphQLError, RateLimited, TokenRejected, too_heavy
15
+ from mergescout.labels import OPPORTUNITY_LABELS
16
+ from mergescout.timeutil import parse_time
17
+
18
+ # GitHub search never returns more than this many results for one query.
19
+ MAX_RESULTS = 1000
20
+ FMT = "%Y-%m-%dT%H:%M:%SZ"
21
+ STAR_BOUNDS = [10, 20, 50, 100, 1000, 10000]
22
+
23
+ COUNT_QUERY = """
24
+ query($q: String!) {
25
+ rateLimit { cost remaining resetAt }
26
+ search(query: $q, type: REPOSITORY, first: 1) { repositoryCount }
27
+ }
28
+ """
29
+
30
+ REPO_QUERY = """
31
+ query($q: String!, $cursor: String, $n: Int!, $labels: [String!]) {
32
+ rateLimit { cost remaining resetAt }
33
+ search(query: $q, type: REPOSITORY, first: $n, after: $cursor) {
34
+ repositoryCount
35
+ pageInfo { hasNextPage endCursor }
36
+ nodes { ... on Repository {
37
+ databaseId nameWithOwner stargazerCount forkCount createdAt pushedAt
38
+ primaryLanguage { name } hasIssuesEnabled licenseInfo { spdxId }
39
+ issues(states: OPEN) { totalCount }
40
+ opp: issues(states: OPEN, labels: $labels) { totalCount }
41
+ } }
42
+ }
43
+ }
44
+ """
45
+
46
+ SCHEMA = pa.schema([
47
+ ("repo_id", pa.int64()),
48
+ ("name", pa.string()),
49
+ ("stars", pa.int32()),
50
+ ("forks", pa.int32()),
51
+ ("created_at", pa.timestamp("us", tz="UTC")),
52
+ ("pushed_at", pa.timestamp("us", tz="UTC")),
53
+ ("language", pa.string()),
54
+ ("has_issues", pa.bool_()),
55
+ ("license", pa.string()),
56
+ ("open_issues", pa.int32()),
57
+ ("open_prs", pa.int32()),
58
+ ("opportunity_issues", pa.int32()),
59
+ ])
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Slice:
64
+ stars_lo: int
65
+ stars_hi: int | None # None means no upper limit
66
+ created_lo: datetime
67
+ created_hi: datetime
68
+ pushed_lo: datetime | None = None # only repos pushed since then (active repos)
69
+
70
+ def query(self) -> str:
71
+ stars = f">={self.stars_lo}" if self.stars_hi is None else f"{self.stars_lo}..{self.stars_hi}"
72
+ span = f"{self.created_lo.strftime(FMT)}..{self.created_hi.strftime(FMT)}"
73
+ pushed = f" pushed:>={self.pushed_lo:%Y-%m-%d}" if self.pushed_lo else ""
74
+ return f"stars:{stars}{pushed} created:{span} fork:false archived:false"
75
+
76
+ def split(self) -> list["Slice"]:
77
+ # Split by stars while the range has more than one value, then by creation time.
78
+ if self.stars_hi is not None and self.stars_hi > self.stars_lo:
79
+ mid = (self.stars_lo + self.stars_hi) // 2
80
+ return [Slice(self.stars_lo, mid, self.created_lo, self.created_hi, self.pushed_lo),
81
+ Slice(mid + 1, self.stars_hi, self.created_lo, self.created_hi, self.pushed_lo)]
82
+ if self.created_hi - self.created_lo <= timedelta(minutes=1):
83
+ return []
84
+ mid = (self.created_lo + (self.created_hi - self.created_lo) / 2).replace(microsecond=0)
85
+ return [Slice(self.stars_lo, self.stars_hi, self.created_lo, mid, self.pushed_lo),
86
+ Slice(self.stars_lo, self.stars_hi, mid + timedelta(seconds=1), self.created_hi, self.pushed_lo)]
87
+
88
+
89
+ def initial_slices(min_stars: int, start: datetime, end: datetime, pushed_lo: datetime | None = None) -> list[Slice]:
90
+ bounds = [b for b in STAR_BOUNDS if b > min_stars]
91
+ lows = [min_stars] + bounds
92
+ highs = [b - 1 for b in bounds] + [None]
93
+ return [Slice(lo, hi, start, end, pushed_lo) for lo, hi in zip(lows, highs)]
94
+
95
+
96
+ def _row(n: dict) -> dict:
97
+ return {
98
+ "repo_id": n.get("databaseId"),
99
+ "name": n["nameWithOwner"],
100
+ "stars": n.get("stargazerCount"),
101
+ "forks": n.get("forkCount"),
102
+ "created_at": parse_time(n.get("createdAt")),
103
+ "pushed_at": parse_time(n.get("pushedAt")),
104
+ "language": (n.get("primaryLanguage") or {}).get("name"),
105
+ "has_issues": n.get("hasIssuesEnabled"),
106
+ "license": (n.get("licenseInfo") or {}).get("spdxId"),
107
+ "open_issues": (n.get("issues") or {}).get("totalCount"),
108
+ "open_prs": (n.get("pullRequests") or {}).get("totalCount"),
109
+ "opportunity_issues": (n.get("opp") or {}).get("totalCount"),
110
+ }
111
+
112
+
113
+ def fetch_slice(gql, sl: Slice, page: int = 50) -> tuple[list[dict] | None, int, int]:
114
+ # Returns (rows, total count, points spent); rows is None when the slice must be split first.
115
+ probe = gql.query(COUNT_QUERY, {"q": sl.query()})
116
+ cost = probe.cost
117
+ count = probe.data["search"]["repositoryCount"]
118
+ if count > MAX_RESULTS and sl.split():
119
+ return None, count, cost
120
+ rows: dict[int | str, dict] = {}
121
+ cursor = None
122
+ while count:
123
+ try:
124
+ result = gql.query(REPO_QUERY, {"q": sl.query(), "cursor": cursor, "n": page,
125
+ "labels": list(OPPORTUNITY_LABELS)})
126
+ except GraphQLError as exc:
127
+ # Big repos make pages heavy and slow; retry the same page with fewer repos.
128
+ if too_heavy(exc) and page > 10:
129
+ page = max(10, page // 2)
130
+ continue
131
+ raise
132
+ cost += result.cost
133
+ found = result.data["search"]
134
+ for n in found["nodes"]:
135
+ if n and n.get("nameWithOwner"):
136
+ row = _row(n)
137
+ rows[row["repo_id"] or row["name"]] = row
138
+ if not found["nodes"] or not found["pageInfo"]["hasNextPage"] or not found["pageInfo"]["endCursor"]:
139
+ break
140
+ if len(rows) >= MAX_RESULTS:
141
+ break
142
+ cursor = found["pageInfo"]["endCursor"]
143
+ return list(rows.values()), count, cost
144
+
145
+
146
+ class SweepState:
147
+ def __init__(self, path: Path, resume: bool = False):
148
+ path.parent.mkdir(parents=True, exist_ok=True)
149
+ self.lock = threading.Lock()
150
+ self.db = sqlite3.connect(path, check_same_thread=False)
151
+ with self.db:
152
+ self.db.execute(
153
+ "CREATE TABLE IF NOT EXISTS slices (key TEXT PRIMARY KEY, stars_lo INTEGER, stars_hi INTEGER, "
154
+ "created_lo TEXT, created_hi TEXT, status TEXT, rows INTEGER, cost INTEGER, error TEXT)"
155
+ )
156
+ self.db.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)")
157
+ if resume:
158
+ # Anything interrupted or failed last time gets another go.
159
+ self.db.execute("UPDATE slices SET status = 'queued' WHERE status IN ('running', 'failed')")
160
+
161
+ def setting(self, key: str, value: str | None = None) -> str | None:
162
+ with self.lock, self.db:
163
+ if value is not None:
164
+ self.db.execute("INSERT OR REPLACE INTO settings VALUES (?, ?)", (key, value))
165
+ row = self.db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
166
+ return row[0] if row else None
167
+
168
+ def seed(self, slices: list[Slice]) -> None:
169
+ with self.lock, self.db:
170
+ if self.db.execute("SELECT COUNT(*) FROM slices").fetchone()[0] == 0:
171
+ self._insert(slices)
172
+
173
+ def _insert(self, slices: list[Slice]) -> None:
174
+ self.db.executemany(
175
+ "INSERT OR IGNORE INTO slices VALUES (?, ?, ?, ?, ?, 'queued', NULL, NULL, NULL)",
176
+ [(s.query(), s.stars_lo, s.stars_hi, s.created_lo.strftime(FMT), s.created_hi.strftime(FMT))
177
+ for s in slices],
178
+ )
179
+
180
+ def claim(self) -> Slice | None:
181
+ pushed = parse_time(self.setting("pushed_after"))
182
+ with self.lock, self.db:
183
+ row = self.db.execute(
184
+ "SELECT key, stars_lo, stars_hi, created_lo, created_hi FROM slices "
185
+ "WHERE status = 'queued' ORDER BY stars_lo DESC LIMIT 1").fetchone()
186
+ if row is None:
187
+ return None
188
+ self.db.execute("UPDATE slices SET status = 'running' WHERE key = ?", (row[0],))
189
+ return Slice(row[1], row[2], parse_time(row[3]), parse_time(row[4]), pushed)
190
+
191
+ def done(self, sl: Slice, rows: int, cost: int) -> None:
192
+ with self.lock, self.db:
193
+ self.db.execute("UPDATE slices SET status = 'done', rows = ?, cost = ? WHERE key = ?",
194
+ (rows, cost, sl.query()))
195
+
196
+ def split(self, sl: Slice, children: list[Slice], cost: int) -> None:
197
+ with self.lock, self.db:
198
+ self._insert(children)
199
+ self.db.execute("UPDATE slices SET status = 'split', cost = ? WHERE key = ?", (cost, sl.query()))
200
+
201
+ def release(self, sl: Slice) -> None:
202
+ with self.lock, self.db:
203
+ self.db.execute("UPDATE slices SET status = 'queued' WHERE key = ?", (sl.query(),))
204
+
205
+ def fail(self, sl: Slice, error: str) -> None:
206
+ with self.lock, self.db:
207
+ self.db.execute("UPDATE slices SET status = 'failed', error = ? WHERE key = ?", (error[:300], sl.query()))
208
+
209
+ def active(self) -> int:
210
+ with self.lock:
211
+ return self.db.execute(
212
+ "SELECT COUNT(*) FROM slices WHERE status IN ('queued', 'running')").fetchone()[0]
213
+
214
+ def counts(self) -> dict:
215
+ with self.lock:
216
+ return dict(self.db.execute("SELECT status, COUNT(*) FROM slices GROUP BY status").fetchall())
217
+
218
+ def totals(self) -> tuple[int, int]:
219
+ with self.lock:
220
+ rows, cost = self.db.execute("SELECT COALESCE(SUM(rows), 0), COALESCE(SUM(cost), 0) FROM slices").fetchone()
221
+ return rows, cost
222
+
223
+
224
+ def _write(rows: list[dict], out: Path) -> None:
225
+ tmp = out.with_name(out.name + ".part")
226
+ pq.write_table(pa.Table.from_pylist(rows, schema=SCHEMA), tmp, compression="zstd")
227
+ os.replace(tmp, out)
228
+
229
+
230
+ def run_sweep(make_gql, state: SweepState, data_dir: Path, workers: int = 4,
231
+ log: Callable[..., None] = print) -> bool:
232
+ # make_gql is one client factory (used by every worker) or a list with one factory per worker.
233
+ # Returns True when slices are left because an identity hit its limit; the next run resumes them.
234
+ out_dir = data_dir / "basics"
235
+ out_dir.mkdir(parents=True, exist_ok=True)
236
+ factories = make_gql if isinstance(make_gql, list) else [make_gql] * workers
237
+
238
+ in_flight = [0]
239
+ flight_lock = threading.Lock()
240
+ limited = threading.Event()
241
+
242
+ def worker(factory) -> None:
243
+ gql = factory()
244
+ while True:
245
+ sl = state.claim()
246
+ if sl is None:
247
+ with flight_lock:
248
+ busy = in_flight[0]
249
+ if busy == 0:
250
+ return
251
+ time.sleep(0.5)
252
+ continue
253
+ with flight_lock:
254
+ in_flight[0] += 1
255
+ try:
256
+ rows, count, cost = fetch_slice(gql, sl)
257
+ if rows is None:
258
+ state.split(sl, sl.split(), cost)
259
+ else:
260
+ if rows:
261
+ _write(rows, out_dir / f"{hashlib.sha1(sl.query().encode()).hexdigest()[:16]}.parquet")
262
+ state.done(sl, len(rows), cost)
263
+ if len(rows) < min(count, MAX_RESULTS):
264
+ log(f"warning: {sl.query()} returned {len(rows)} of {count} repos")
265
+ except (RateLimited, TokenRejected) as exc:
266
+ # This worker's identity is out for now; the slice goes back for the others or the next run.
267
+ state.release(sl)
268
+ limited.set()
269
+ log(f"worker stops: {exc}")
270
+ return # the finally below still marks this slice as no longer in flight
271
+ except Exception as exc:
272
+ # Anything that breaks after the fetch must not leave the slice stuck as running.
273
+ state.fail(sl, f"{type(exc).__name__}: {exc}")
274
+ log(f"failed {sl.query()}: {exc}")
275
+ finally:
276
+ with flight_lock:
277
+ in_flight[0] -= 1
278
+ total_rows, total_cost = state.totals()
279
+ log(f"{datetime.now(timezone.utc):%H:%M:%S} repos={total_rows:,} points={total_cost:,} {state.counts()}")
280
+
281
+ threads = [threading.Thread(target=worker, args=(f,), daemon=True) for f in factories]
282
+ for t in threads:
283
+ t.start()
284
+ for t in threads:
285
+ t.join()
286
+ return limited.is_set() and state.counts().get("queued", 0) > 0
File without changes
@@ -0,0 +1,202 @@
1
+ import os
2
+ import threading
3
+ import time
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+
8
+ import httpx
9
+
10
+ from mergescout.timeutil import parse_time
11
+
12
+
13
+ class GraphQLError(RuntimeError):
14
+ pass
15
+
16
+
17
+ class NotFound(GraphQLError):
18
+ pass
19
+
20
+
21
+ class RateLimited(GraphQLError):
22
+ pass
23
+
24
+
25
+ class TokenRejected(GraphQLError):
26
+ # A token that stopped working (expired App token, revoked token): that identity steps aside.
27
+ pass
28
+
29
+
30
+ class Gate:
31
+ # Shared by every client on one token: when GitHub says "wait", all of them wait, not just the one told.
32
+ def __init__(self, clock: Callable[[], float] = time.monotonic):
33
+ self.clock = clock
34
+ self.lock = threading.Lock()
35
+ self.until = 0.0
36
+ self.stats = {"requests": 0, "timeouts": 0, "rate_waits": 0, "waited_s": 0.0}
37
+
38
+ def note(self, key: str, amount: float = 1) -> None:
39
+ with self.lock:
40
+ self.stats[key] += amount
41
+
42
+ def summary(self) -> str:
43
+ s = self.stats
44
+ return (f"requests={s['requests']} timeouts={s['timeouts']} rate-limit waits={s['rate_waits']} "
45
+ f"waited={s['waited_s']:.0f}s")
46
+
47
+ def hold(self, seconds: float) -> None:
48
+ with self.lock:
49
+ self.until = max(self.until, self.clock() + seconds)
50
+
51
+ def remaining(self) -> float:
52
+ with self.lock:
53
+ return max(0.0, self.until - self.clock())
54
+
55
+
56
+ def too_heavy(exc: Exception) -> bool:
57
+ # GitHub's ways of saying a page was too big or too slow; callers retry with a smaller page.
58
+ msg = str(exc).lower()
59
+ return any(s in msg for s in ("resource limits", "timeout", "timed out", "kept failing (http 502",
60
+ "something went wrong", "not json"))
61
+
62
+
63
+ @dataclass
64
+ class GraphQLResult:
65
+ data: dict
66
+ cost: int
67
+ remaining: int
68
+ reset_at: str
69
+
70
+
71
+ class GitHubGraphQL:
72
+ # GitHub Enterprise Server's endpoint when Actions names one.
73
+ URL = os.environ.get("GITHUB_GRAPHQL_URL") or "https://api.github.com/graphql"
74
+ RETRY_STATUSES = (500, 502, 503, 504)
75
+ TIMEOUT_STATUSES = (502, 504)
76
+
77
+ def __init__(self, token: str, *, client: httpx.Client | None = None, max_retries: int = 5,
78
+ reserve: int = 200, sleep: Callable[[float], None] = time.sleep,
79
+ now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
80
+ max_wait: float | Callable[[], float] | None = None,
81
+ timeout_retries: int = 1, gate: Gate | None = None):
82
+ self.client = client or httpx.Client(timeout=httpx.Timeout(60.0, connect=15.0))
83
+ # Tokens pasted into settings often carry a stray space or line break.
84
+ self.headers = {"Authorization": f"Bearer {token.strip()}", "User-Agent": "mergescout"}
85
+ self.max_retries = max_retries
86
+ self.reserve = reserve
87
+ self.sleep = sleep
88
+ self.now = now
89
+ # A crawl on paid runner minutes stops instead of sleeping until the hourly reset.
90
+ self.max_wait = max_wait
91
+ self.timeout_retries = timeout_retries
92
+ self.last_remaining: int | None = None
93
+ self.gate = gate or Gate()
94
+
95
+ def query(self, query: str, variables: dict | None = None, *, partial: bool = False) -> GraphQLResult:
96
+ timeouts = 0
97
+ for attempt in range(self.max_retries + 1):
98
+ shared = self.gate.remaining()
99
+ if shared > 0:
100
+ self._pause(shared)
101
+ self.gate.note("requests")
102
+ try:
103
+ resp = self.client.post(self.URL, json={"query": query, "variables": variables or {}},
104
+ headers=self.headers)
105
+ except httpx.LocalProtocolError as exc:
106
+ # The request itself is malformed (usually a bad character in the token); retrying cannot help.
107
+ raise GraphQLError(f"GitHub request could not be sent ({exc}); check the token for spaces or "
108
+ f"line breaks") from exc
109
+ except httpx.TransportError as exc:
110
+ # Wi-Fi drops, DNS hiccups and timeouts are retried like server errors.
111
+ if attempt == self.max_retries:
112
+ raise GraphQLError(f"GitHub unreachable (network error: {exc}) after {attempt} retries") from exc
113
+ self.sleep(min(60, 2 ** attempt * 2))
114
+ continue
115
+ if resp.status_code == 401:
116
+ raise TokenRejected("GitHub rejected the token (401). Check GITHUB_TOKEN.")
117
+ if resp.status_code in self.TIMEOUT_STATUSES:
118
+ self.gate.note("timeouts")
119
+ # GitHub stops queries after 10 s; retry once, then let the caller ask for a smaller page.
120
+ if timeouts >= self.timeout_retries:
121
+ raise GraphQLError(f"GitHub timed out (HTTP {resp.status_code}); the query may be too heavy")
122
+ timeouts += 1
123
+ if resp.status_code in self.RETRY_STATUSES or self._is_rate_limited(resp):
124
+ if attempt == self.max_retries:
125
+ raise GraphQLError(f"GitHub kept failing (HTTP {resp.status_code}) after {attempt} retries")
126
+ wait = self._wait_for(resp, attempt)
127
+ if self._is_rate_limited(resp):
128
+ self.gate.hold(wait)
129
+ self.gate.note("rate_waits")
130
+ self._pause(wait)
131
+ continue
132
+ if resp.status_code != 200:
133
+ raise GraphQLError(f"GitHub returned HTTP {resp.status_code}: {resp.text[:200]}")
134
+ try:
135
+ body = resp.json()
136
+ except ValueError as exc:
137
+ raise GraphQLError(f"GitHub returned a body that is not JSON: {resp.text[:200]}") from exc
138
+ errors = body.get("errors") or []
139
+ data = body.get("data") or {}
140
+ if errors:
141
+ types = {e.get("type") for e in errors}
142
+ if "RATE_LIMITED" in types and attempt < self.max_retries:
143
+ wait = self._seconds_until((data.get("rateLimit") or {}).get("resetAt"), fallback=60)
144
+ self.gate.hold(wait)
145
+ self.gate.note("rate_waits")
146
+ self._pause(wait)
147
+ continue
148
+ top = [e for e in errors if e.get("type") == "NOT_FOUND" and e.get("path") == ["repository"]]
149
+ if top and (partial or len(top) == len(errors)):
150
+ raise NotFound(top[0].get("message", "not found"))
151
+ # With partial, items that no longer exist come back as null instead of failing the whole page.
152
+ if not (partial and all(e.get("type") == "NOT_FOUND" for e in errors)):
153
+ raise GraphQLError("; ".join(sorted({e.get("message", "unknown error") for e in errors})))
154
+ rate = data.get("rateLimit")
155
+ if rate is None:
156
+ raise ValueError("Every query must request rateLimit { cost remaining resetAt }")
157
+ result = GraphQLResult(data, rate["cost"], rate["remaining"], rate["resetAt"])
158
+ self.last_remaining = result.remaining
159
+ if result.remaining < self.reserve:
160
+ wait = self._seconds_until(result.reset_at, fallback=0)
161
+ limit = self._wait_limit()
162
+ if limit is None or wait <= limit:
163
+ self.gate.hold(wait)
164
+ self.sleep(wait)
165
+ return result
166
+ raise GraphQLError("unreachable")
167
+
168
+ @staticmethod
169
+ def _is_rate_limited(resp: httpx.Response) -> bool:
170
+ if resp.status_code == 429:
171
+ return True
172
+ return resp.status_code == 403 and (
173
+ "rate limit" in resp.text.lower() or resp.headers.get("x-ratelimit-remaining") == "0")
174
+
175
+ def _wait_for(self, resp: httpx.Response, attempt: int) -> float:
176
+ retry_after = resp.headers.get("retry-after")
177
+ if retry_after and retry_after.isdigit():
178
+ return int(retry_after)
179
+ if resp.headers.get("x-ratelimit-remaining") == "0" and resp.headers.get("x-ratelimit-reset", "").isdigit():
180
+ reset = datetime.fromtimestamp(int(resp.headers["x-ratelimit-reset"]), tz=timezone.utc)
181
+ return max(0.0, (reset - self.now()).total_seconds() + 5)
182
+ if self._is_rate_limited(resp):
183
+ # GitHub asks for at least a minute after a secondary limit.
184
+ return 60
185
+ return min(60, 2 ** attempt * 2)
186
+
187
+ def _wait_limit(self) -> float | None:
188
+ # max_wait may be a function, so a crawl can sit out any wait that still fits in its time window.
189
+ return self.max_wait() if callable(self.max_wait) else self.max_wait
190
+
191
+ def _pause(self, seconds: float) -> None:
192
+ limit = self._wait_limit()
193
+ if limit is not None and seconds > limit:
194
+ raise RateLimited(f"GitHub asks to wait {int(seconds)} s for the rate limit (allowed {int(limit)} s)")
195
+ self.gate.note("waited_s", seconds)
196
+ self.sleep(seconds)
197
+
198
+ def _seconds_until(self, reset_at: str | None, fallback: float) -> float:
199
+ reset = parse_time(reset_at)
200
+ if reset is None:
201
+ return fallback
202
+ return max(0.0, (reset - self.now()).total_seconds() + 5)
@@ -0,0 +1,35 @@
1
+ import os
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass
4
+
5
+ from mergescout.github.graphql import Gate, GitHubGraphQL
6
+
7
+ # Hourly points relative to a personal token; GitHub reported 5,000 for all three on 2026-10-15.
8
+ SHARES = {"personal": 1.0, "app": 1.0, "actions": 1.0}
9
+
10
+
11
+ @dataclass
12
+ class Identity:
13
+ name: str
14
+ make_gql: Callable
15
+ gate: Gate
16
+ share: float = 1.0
17
+
18
+
19
+ def slots(in_flight: int, share: float) -> int:
20
+ return max(1, round(in_flight * share))
21
+
22
+
23
+ def identities_from_env(primary_token: str, max_wait: float | None, client=GitHubGraphQL) -> list[Identity]:
24
+ # Each identity has its own limits, so each gets its own shared wait; the same token is never used twice.
25
+ found = [("personal", primary_token), ("app", os.environ.get("MS_APP_TOKEN")),
26
+ ("actions", os.environ.get("MS_ACTIONS_TOKEN"))]
27
+ out, seen = [], set()
28
+ for name, token in found:
29
+ token = (token or "").strip()
30
+ if not token or token in seen:
31
+ continue
32
+ seen.add(token)
33
+ gate = Gate()
34
+ out.append(Identity(name, lambda t=token, g=gate: client(t, max_wait=max_wait, gate=g), gate, SHARES[name]))
35
+ return out
@@ -0,0 +1,137 @@
1
+ import os
2
+ import time
3
+ from pathlib import Path
4
+
5
+ import httpx
6
+
7
+ API = "https://api.github.com"
8
+ UPLOADS = "https://uploads.github.com"
9
+
10
+
11
+ class ReleaseError(RuntimeError):
12
+ pass
13
+
14
+
15
+ class Releases:
16
+ # Release assets in our own repo are the durable store for crawl data and state backups.
17
+ def __init__(self, token: str | None, repo: str, client: httpx.Client | None = None, sleep=time.sleep, clock=time.time,
18
+ max_wait: float = 600): # the crawl job has 70 min: 52 crawling, a few to push
19
+ self.repo, self.sleep, self.clock, self.max_wait = repo, sleep, clock, max_wait
20
+ self.client = client or httpx.Client(timeout=httpx.Timeout(300.0, connect=15.0), follow_redirects=True)
21
+ self.headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28",
22
+ "User-Agent": "mergescout"}
23
+ # Public data downloads need no token; private repos and uploads do.
24
+ if token and token.strip():
25
+ self.headers["Authorization"] = f"Bearer {token.strip()}"
26
+
27
+ @staticmethod
28
+ def _check(resp: httpx.Response, what: str) -> httpx.Response:
29
+ if resp.status_code >= 400:
30
+ raise ReleaseError(f"{what} failed: HTTP {resp.status_code} {resp.text[:200]}")
31
+ return resp
32
+
33
+ def _limited_wait(self, resp: httpx.Response) -> float | None:
34
+ # Seconds until the account's hourly limit resets, when that is why the request failed and the wait is short.
35
+ if resp.status_code not in (403, 429) or resp.headers.get("x-ratelimit-remaining") != "0":
36
+ return None
37
+ wait = float(resp.headers.get("x-ratelimit-reset") or 0) - self.clock() + 5
38
+ return wait if 0 < wait <= self.max_wait else None
39
+
40
+ def get(self, tag: str) -> dict | None:
41
+ resp = self.client.get(f"{API}/repos/{self.repo}/releases/tags/{tag}", headers=self.headers)
42
+ if resp.status_code == 404:
43
+ return None
44
+ return self._check(resp, f"reading release {tag}").json()
45
+
46
+ def ensure(self, tag: str) -> dict:
47
+ found = self.get(tag)
48
+ if found:
49
+ return found
50
+ body = {"tag_name": tag, "name": tag, "body": "Automated MergeScout data store.", "prerelease": True,
51
+ "make_latest": "false"}
52
+ resp = self.client.post(f"{API}/repos/{self.repo}/releases", headers=self.headers, json=body)
53
+ return self._check(resp, f"creating release {tag}").json()
54
+
55
+ def assets(self, release: dict) -> list[dict]:
56
+ out, page = [], 1
57
+ while True:
58
+ resp = self.client.get(f"{API}/repos/{self.repo}/releases/{release['id']}/assets", headers=self.headers,
59
+ params={"per_page": 100, "page": page})
60
+ batch = self._check(resp, "listing release assets").json()
61
+ out.extend(batch)
62
+ if len(batch) < 100:
63
+ return out
64
+ page += 1
65
+
66
+ def upload(self, tag: str, path: Path, name: str | None = None) -> dict:
67
+ release = self.ensure(tag)
68
+ name = name or path.name
69
+ existing = {a["name"]: a for a in self.assets(release)}
70
+ # A file being replaced is uploaded under a temporary name first, so a failed upload keeps the old copy.
71
+ target = f"{name}.new" if name in existing else name
72
+ if target in existing:
73
+ self.delete_asset(existing[target])
74
+ headers = {**self.headers, "Content-Type": "application/octet-stream",
75
+ "Content-Length": str(path.stat().st_size)}
76
+ for attempt in range(2):
77
+ with path.open("rb") as f:
78
+ resp = self.client.post(f"{UPLOADS}/repos/{self.repo}/releases/{release['id']}/assets",
79
+ params={"name": target}, headers=headers, content=f)
80
+ # The hour's work is in this file: wait for the reset once rather than lose it (real failure 2026-10-20).
81
+ wait = self._limited_wait(resp) if attempt == 0 else None
82
+ if wait is None:
83
+ break
84
+ self.sleep(wait)
85
+ uploaded = self._check(resp, f"uploading {name}").json()
86
+ if target == name:
87
+ return uploaded
88
+ self.delete_asset(existing[name])
89
+ resp = self.client.patch(f"{API}/repos/{self.repo}/releases/assets/{uploaded['id']}", headers=self.headers,
90
+ json={"name": name})
91
+ return self._check(resp, f"renaming {target}").json()
92
+
93
+ def list_releases(self) -> list[dict]:
94
+ out, page = [], 1
95
+ while True:
96
+ resp = self.client.get(f"{API}/repos/{self.repo}/releases", headers=self.headers,
97
+ params={"per_page": 100, "page": page})
98
+ batch = self._check(resp, "listing releases").json()
99
+ out.extend(batch)
100
+ if len(batch) < 100:
101
+ return out
102
+ page += 1
103
+
104
+ def delete_asset(self, asset: dict) -> None:
105
+ self._check(self.client.delete(f"{API}/repos/{self.repo}/releases/assets/{asset['id']}", headers=self.headers),
106
+ f"deleting {asset.get('name', asset['id'])}")
107
+
108
+ def delete_release(self, release: dict) -> None:
109
+ self._check(self.client.delete(f"{API}/repos/{self.repo}/releases/{release['id']}", headers=self.headers),
110
+ f"deleting release {release['tag_name']}")
111
+ # The tag stays behind otherwise; a missing tag is fine.
112
+ resp = self.client.delete(f"{API}/repos/{self.repo}/git/refs/tags/{release['tag_name']}", headers=self.headers)
113
+ if resp.status_code not in (204, 404, 422):
114
+ self._check(resp, f"deleting tag {release['tag_name']}")
115
+
116
+ def download(self, tag: str, name: str, dest: Path) -> bool:
117
+ release = self.get(tag)
118
+ asset = next((a for a in self.assets(release) if a["name"] == name), None) if release else None
119
+ if asset is None:
120
+ return False
121
+ self.download_asset(asset, dest)
122
+ return True
123
+
124
+ def download_asset(self, asset: dict, dest: Path) -> None:
125
+ name = asset.get("name", asset["id"])
126
+ dest.parent.mkdir(parents=True, exist_ok=True)
127
+ part = dest.with_name(dest.name + ".part")
128
+ headers = {**self.headers, "Accept": "application/octet-stream"}
129
+ with self.client.stream("GET", f"{API}/repos/{self.repo}/releases/assets/{asset['id']}",
130
+ headers=headers) as resp:
131
+ if resp.status_code >= 400:
132
+ resp.read()
133
+ self._check(resp, f"downloading {name}")
134
+ with part.open("wb") as f:
135
+ for chunk in resp.iter_bytes():
136
+ f.write(chunk)
137
+ os.replace(part, dest)