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,34 @@
1
+ # The file every user downloads; bump the major version only for changes old tools cannot read.
2
+ SCHEMA_VERSION = "1.1"
3
+ SCHEMA_MAJOR = 1
4
+
5
+ DDL = """
6
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
7
+ CREATE TABLE repos (
8
+ repo_id INTEGER PRIMARY KEY, name TEXT NOT NULL, stars INTEGER, language TEXT COLLATE NOCASE, pushed_at TEXT,
9
+ archived INTEGER, license TEXT, has_discussions INTEGER, open_issues INTEGER, open_prs INTEGER, insiders INTEGER,
10
+ outsider_prs INTEGER, merged INTEGER, closed INTEGER, merge_rate REAL, merge_low REAL, merge_high REAL,
11
+ merge_enough INTEGER, newcomer_merged INTEGER, newcomer_decided INTEGER, newcomer_rate REAL, newcomer_low REAL,
12
+ newcomer_high REAL, newcomer_enough INTEGER, reviewed INTEGER, median_review_hours REAL, review_enough INTEGER,
13
+ window_days INTEGER, window_complete INTEGER, ai_prs INTEGER, bot_prs INTEGER, insider_prs INTEGER,
14
+ blocking_walls INTEGER NOT NULL, walls_json TEXT NOT NULL, labels_json TEXT NOT NULL, notes TEXT NOT NULL,
15
+ contributing INTEGER,
16
+ fetched_at TEXT);
17
+ CREATE UNIQUE INDEX repos_name ON repos (name COLLATE NOCASE);
18
+ CREATE TABLE issues (
19
+ repo_id INTEGER NOT NULL, number INTEGER NOT NULL, title TEXT, body TEXT, labels TEXT NOT NULL,
20
+ language TEXT COLLATE NOCASE, created_at TEXT, updated_at TEXT, available INTEGER NOT NULL,
21
+ opportunity INTEGER NOT NULL, blocked_by TEXT, assignees INTEGER, linked_open_prs INTEGER, dead_ends INTEGER,
22
+ claimers INTEGER, last_claim_at TEXT, interest_30d INTEGER, score REAL NOT NULL, rank_score REAL NOT NULL,
23
+ p_avail REAL, p_merge REAL, p_reply REAL, p_fixed REAL, p_quality REAL, mult REAL, fetched_at TEXT,
24
+ beginner INTEGER NOT NULL DEFAULT 0, kind TEXT,
25
+ UNIQUE (repo_id, number));
26
+ CREATE INDEX issues_rank ON issues (available, language, rank_score DESC);
27
+ CREATE INDEX issues_rank_all ON issues (available, rank_score DESC);
28
+ CREATE TABLE outsider_prs (repo_id INTEGER NOT NULL, number INTEGER NOT NULL, merged_at TEXT, lines INTEGER,
29
+ files INTEGER, days_to_merge REAL);
30
+ CREATE INDEX outsider_prs_repo ON outsider_prs (repo_id);
31
+ -- detail=column: single-word search only, about half the index size.
32
+ CREATE VIRTUAL TABLE issues_fts USING fts5(title, labels, body, content='issues', content_rowid='rowid',
33
+ tokenize='porter unicode61', detail=column);
34
+ """
@@ -0,0 +1,30 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class Weights:
6
+ # Architecture §6 until calibrated odds exist; every number here is config, not a law.
7
+ availability: float = 0.30
8
+ outsider_merge: float = 0.30
9
+ reply: float = 0.15
10
+ fixed_before: float = 0.10
11
+ issue_quality: float = 0.15
12
+ prior_merge: float = 0.2
13
+ prior_reply: float = 0.3
14
+ prior_fixed: float = 0.3
15
+ reply_half_hours: float = 48.0
16
+ dead_end: float = 0.8
17
+ dead_end_floor: float = 0.5
18
+ crowded_interest: int = 3
19
+ crowded: float = 0.7
20
+ blocking_wall: float = 0.85
21
+ freshness_half_days: float = 30.0
22
+ discussion: float = 0.3
23
+ untriaged: float = 0.6
24
+ old_days: int = 365
25
+ old: float = 0.75
26
+ very_old_days: int = 730
27
+ very_old: float = 0.5
28
+
29
+
30
+ WEIGHTS = Weights()
File without changes
@@ -0,0 +1,46 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from mergescout.config import load_config
8
+ from mergescout.github.releases import Releases
9
+ from mergescout.store.sync import pull, push
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ parser = argparse.ArgumentParser(prog="python -m mergescout.store",
14
+ description="Keep crawl data and state in this repo's GitHub releases.")
15
+ sub = parser.add_subparsers(dest="cmd", required=True)
16
+ ps = sub.add_parser("push", help="Upload new crawl batches and a state backup, then clear uploaded batches")
17
+ ps.add_argument("--with-discover", action="store_true", help="Also back up the sweep results")
18
+ sub.add_parser("pull", help="Restore state from the releases when the local copy is missing")
19
+ put = sub.add_parser("put", help="Upload one file to a release tag")
20
+ put.add_argument("path")
21
+ put.add_argument("--tag", required=True)
22
+ # MS_STORE_REPO lets a crawler repo keep its data in another (private) repo; otherwise the running repo.
23
+ parser.add_argument("--repo", default=os.environ.get("MS_STORE_REPO") or os.environ.get("GITHUB_REPOSITORY"),
24
+ help="owner/name of the repo whose releases hold the data")
25
+ args = parser.parse_args(argv)
26
+
27
+ token = os.environ.get("MS_RELEASE_TOKEN")
28
+ if not token or not args.repo:
29
+ print("Set MS_RELEASE_TOKEN (a token that can write releases) and GITHUB_REPOSITORY or --repo.",
30
+ file=sys.stderr)
31
+ return 1
32
+ cfg = load_config()
33
+ releases = Releases(token, args.repo)
34
+ log = lambda msg: print(msg, flush=True) # noqa: E731
35
+ if args.cmd == "push":
36
+ push(cfg.data_dir, releases, datetime.now(timezone.utc), with_discover=args.with_discover, log=log)
37
+ elif args.cmd == "put":
38
+ releases.upload(args.tag, Path(args.path))
39
+ log(f"uploaded {Path(args.path).name} to {args.tag}")
40
+ else:
41
+ pull(cfg.data_dir, releases, log=log)
42
+ return 0
43
+
44
+
45
+ if __name__ == "__main__":
46
+ raise SystemExit(main())
@@ -0,0 +1,148 @@
1
+ import gzip
2
+ import os
3
+ import re
4
+ import shutil
5
+ import sqlite3
6
+ import tarfile
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ STATE_TAG = "crawl-state"
11
+
12
+
13
+ def data_tag(now: datetime) -> str:
14
+ # One data release per ISO week: hourly runs stay well under GitHub's 1,000 files per release.
15
+ return f"crawl-{now:%G-W%V}"
16
+ RUN_FILE = re.compile(r"^run=(?P<run>.+)-(?P<seq>\d{3})\.parquet$")
17
+
18
+
19
+ def pending_runs(data_dir: Path) -> dict[str, list[Path]]:
20
+ runs: dict[str, list[Path]] = {}
21
+ for p in sorted((data_dir / "crawl").glob("*/run=*.parquet")):
22
+ m = RUN_FILE.match(p.name)
23
+ if m:
24
+ runs.setdefault(m["run"], []).append(p)
25
+ return runs
26
+
27
+
28
+ def _tar(paths: list[Path], base: Path, out: Path) -> Path:
29
+ with tarfile.open(out, "w") as tar:
30
+ for p in paths:
31
+ tar.add(p, arcname=p.relative_to(base).as_posix())
32
+ return out
33
+
34
+
35
+ def safe_extract(archive: Path, dest: Path) -> None:
36
+ # Only plain files and folders that land inside dest; anything else means a broken or hostile archive.
37
+ root = dest.resolve()
38
+ with tarfile.open(archive) as tar:
39
+ members = tar.getmembers()
40
+ for m in members:
41
+ target = (dest / m.name).resolve()
42
+ if not (m.isfile() or m.isdir()) or (target != root and root not in target.parents):
43
+ raise ValueError(f"refusing archive entry {m.name!r}")
44
+ dest.mkdir(parents=True, exist_ok=True)
45
+ for m in members:
46
+ target = dest / m.name
47
+ if m.isdir():
48
+ target.mkdir(parents=True, exist_ok=True)
49
+ continue
50
+ target.parent.mkdir(parents=True, exist_ok=True)
51
+ with tar.extractfile(m) as src, target.open("wb") as out:
52
+ shutil.copyfileobj(src, out)
53
+
54
+
55
+ def _repo_count(state_file: Path) -> int:
56
+ db = sqlite3.connect(state_file)
57
+ try:
58
+ return db.execute("SELECT count(*) FROM repos").fetchone()[0]
59
+ except sqlite3.Error:
60
+ return 0
61
+ finally:
62
+ db.close()
63
+
64
+
65
+ def push(data_dir: Path, releases, now: datetime, with_discover: bool = False, log=print) -> dict:
66
+ # Upload first, delete after: a failed upload leaves the files for the next run to retry.
67
+ outbox = data_dir / "outbox"
68
+ outbox.mkdir(parents=True, exist_ok=True)
69
+ out = {"runs": 0, "state": False, "discover": False}
70
+ for run, files in pending_runs(data_dir).items():
71
+ bundle = _tar(files, data_dir, outbox / f"crawl-{run}.tar")
72
+ releases.upload(data_tag(now), bundle)
73
+ for p in files:
74
+ p.unlink()
75
+ bundle.unlink()
76
+ out["runs"] += 1
77
+ state_file = data_dir / "crawl.sqlite"
78
+ if state_file.exists() and _repo_count(state_file) == 0:
79
+ # An empty queue means the state was not restored; never let it replace the good backup.
80
+ log("not backing up an empty crawl state")
81
+ elif state_file.exists():
82
+ snap, packed = outbox / "crawl.sqlite", outbox / "crawl.sqlite.gz"
83
+ src, dst = sqlite3.connect(state_file), sqlite3.connect(snap)
84
+ with dst:
85
+ src.backup(dst)
86
+ src.close()
87
+ dst.close()
88
+ with snap.open("rb") as f, gzip.open(packed, "wb") as g:
89
+ shutil.copyfileobj(f, g)
90
+ releases.upload(STATE_TAG, packed)
91
+ snap.unlink()
92
+ packed.unlink()
93
+ out["state"] = True
94
+ discover = data_dir / "discover"
95
+ if with_discover and discover.exists():
96
+ files = [p for p in sorted(discover.rglob("*")) if p.is_file()]
97
+ bundle = _tar(files, data_dir, outbox / "discover.tar")
98
+ releases.upload(STATE_TAG, bundle)
99
+ bundle.unlink()
100
+ out["discover"] = True
101
+ log(f"pushed: {out}")
102
+ return out
103
+
104
+
105
+ def pull(data_dir: Path, releases, log=print) -> dict:
106
+ # Only used when the Actions cache is gone; local files always win.
107
+ outbox = data_dir / "outbox"
108
+ out = {"state": False, "discover": False}
109
+ state_file = data_dir / "crawl.sqlite"
110
+ if not state_file.exists():
111
+ packed = outbox / "crawl.sqlite.gz"
112
+ if releases.download(STATE_TAG, "crawl.sqlite.gz", packed):
113
+ part = state_file.with_name(state_file.name + ".part")
114
+ with gzip.open(packed, "rb") as g, part.open("wb") as f:
115
+ shutil.copyfileobj(g, f)
116
+ os.replace(part, state_file)
117
+ packed.unlink()
118
+ out["state"] = True
119
+ if not (data_dir / "discover").exists():
120
+ bundle = outbox / "discover.tar"
121
+ if releases.download(STATE_TAG, "discover.tar", bundle):
122
+ safe_extract(bundle, data_dir)
123
+ bundle.unlink()
124
+ out["discover"] = True
125
+ log(f"pulled: {out}")
126
+ return out
127
+
128
+
129
+ def fetch_bronze(releases, data_dir: Path, log=print) -> int:
130
+ # Each crawl bundle (weekly and older monthly data releases) is downloaded and unpacked once.
131
+ bronze, got = data_dir / "bronze", 0
132
+ for release in releases.list_releases():
133
+ if not release["tag_name"].startswith("crawl-") or release["tag_name"] == STATE_TAG:
134
+ continue
135
+ for asset in releases.assets(release):
136
+ name = asset["name"]
137
+ marker = bronze / "done" / name
138
+ if not (name.startswith("crawl-") and name.endswith(".tar")) or marker.exists():
139
+ continue
140
+ tar = bronze / "tars" / name
141
+ releases.download_asset(asset, tar)
142
+ safe_extract(tar, bronze)
143
+ tar.unlink()
144
+ marker.parent.mkdir(parents=True, exist_ok=True)
145
+ marker.touch()
146
+ got += 1
147
+ log(f"bronze: {got} new bundles")
148
+ return got
mergescout/timeutil.py ADDED
@@ -0,0 +1,11 @@
1
+ from datetime import datetime
2
+
3
+
4
+ def parse_time(value: str | None) -> datetime | None:
5
+ # GitHub times look like 2026-10-01T15:00:01Z
6
+ if not value:
7
+ return None
8
+ try:
9
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
10
+ except ValueError:
11
+ return None
File without changes
@@ -0,0 +1,62 @@
1
+ import json
2
+ import statistics
3
+
4
+ from mergescout.labels import norm_label
5
+ from mergescout.tool.queries import rate_text, review_text
6
+ from mergescout.tool.refs import Ref
7
+
8
+ CHECKLIST = {
9
+ "cla": "Sign the project's CLA before opening the PR",
10
+ "dco": "Sign off every commit (git commit -s)",
11
+ "issue_first": "Comment on the issue and wait for a maintainer's OK before coding",
12
+ "ai_ban": "This project does not accept AI-written contributions; write it yourself",
13
+ "ai_policy": "Follow the project's AI rules and say in the PR how you used AI",
14
+ "no_outside_prs": "The project says it does not accept outside PRs right now",
15
+ "issues_disabled": "Issues are turned off; ask in the project's discussion channel first",
16
+ }
17
+
18
+
19
+ def build_action_pack(snap, ref: Ref, status: dict | None = None) -> dict:
20
+ issue, repo = snap.issue(ref.repo, ref.number), snap.repo(ref.repo)
21
+ if issue is None or repo is None:
22
+ raise ValueError(f"{ref.key} is not in today's snapshot; use check_issue for a live look.")
23
+ walls = json.loads(repo["walls_json"])
24
+ labels = {lb["label"]: lb for lb in json.loads(repo["labels_json"])}
25
+ examples = snap.examples(repo["repo_id"])
26
+ names = [norm_label(x) for x in json.loads(issue["labels"])]
27
+ fix = next((labels[lb] for lb in names if lb in labels and labels[lb].get("enough")), None)
28
+ if fix and fix.get("median_fix_lines") is not None:
29
+ level = f"typical fix for this label here: {fix['median_fix_lines']:.0f} lines in {fix['median_fix_files']:.0f} files"
30
+ elif examples:
31
+ level = f"recent accepted outsider PRs: median {statistics.median(e['lines'] or 0 for e in examples):.0f} lines"
32
+ else:
33
+ level = "not enough evidence to size this yet"
34
+ status = status or {"available": bool(issue["available"]),
35
+ "reasons": [b for b in (issue["blocked_by"] or "").split(", ") if b], "source": "snapshot",
36
+ "note": f"from the snapshot of {snap.built_at}; call check_issue for the live state"}
37
+ steps = []
38
+ if not status.get("available", True):
39
+ steps.append(f"Stop: this issue is not free right now ({', '.join(status.get('reasons') or [])}). "
40
+ "Pick another one or ask the person working on it.")
41
+ if any(w["wall"] == "issue_first" for w in walls):
42
+ steps.append("Post the draft comment and wait for a maintainer's reply before writing code.")
43
+ steps += ["Reproduce the problem locally and write down the exact steps.",
44
+ "Plan a test that fails before your change and passes after it.",
45
+ "Before opening the PR, explain the change in your own words in the description (no copy-paste).",
46
+ "Keep the PR small and link the issue with 'Fixes #%d'." % ref.number]
47
+ if any(w["wall"] in ("ai_policy", "ai_ban") for w in walls):
48
+ steps.append("Check the project's AI rules (see the checklist) before using an AI assistant.")
49
+ return {"issue": {"ref": ref.key, "url": f"https://github.com/{repo['name']}/issues/{ref.number}",
50
+ "title": issue["title"], "labels": json.loads(issue["labels"]), "asks": issue["body"]},
51
+ "status": status,
52
+ "repo": {"outsider_merge": rate_text(repo), "review": review_text(repo),
53
+ "how_prs_die": f"{repo['closed'] or 0} of {(repo['merged'] or 0) + (repo['closed'] or 0)} decided "
54
+ f"outsider PRs were closed without merging (last {repo['window_days']} days)",
55
+ "contributing": bool(repo["contributing"]), "discussions": bool(repo["has_discussions"])},
56
+ "accepted_examples": [{"url": f"https://github.com/{repo['name']}/pull/{e['number']}", "lines": e["lines"],
57
+ "files": e["files"], "days_to_merge": e["days_to_merge"]} for e in examples],
58
+ "policy_checklist": [f"{CHECKLIST[w['wall']]} (project text: \"{w['evidence']}\")" for w in walls
59
+ if w["wall"] in CHECKLIST],
60
+ "level": level, "next_steps": steps,
61
+ "draft_comment": ("Hi! I'd like to work on this. My plan: <one or two lines on the approach>. "
62
+ "Does that sound right, and could it be assigned to me?")}
@@ -0,0 +1,201 @@
1
+ import math
2
+ import re
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ import httpx
6
+
7
+ from mergescout.labels import has_opportunity, norm_label
8
+ from mergescout.signals.rules import (BOT_NAME_PATTERN, CLAIM_PATTERN, HUMAN_BOT_NAMES, KNOWN_BOTS, RULES,
9
+ TRIAGE_LABEL_PATTERN, UNKNOWN_LOGINS, WAIT_LABEL_PATTERN, WAIT_PATTERN)
10
+
11
+ API = "https://api.github.com"
12
+ INSIDER = {"OWNER", "MEMBER", "COLLABORATOR"}
13
+ FIELDS = """
14
+ fragment S on Issue {
15
+ title state stateReason updatedAt
16
+ assignees(first: 5) { totalCount }
17
+ labels(first: 20) { nodes { name } }
18
+ closedByPullRequestsReferences(first: 5, includeClosedPrs: false) { nodes { number state } }
19
+ timelineItems(last: 30, itemTypes: [CROSS_REFERENCED_EVENT]) { nodes { ... on CrossReferencedEvent {
20
+ source { __typename ... on PullRequest { number state author { __typename login } repository { nameWithOwner } } } } } }
21
+ comments(last: 10) { nodes { createdAt authorAssociation author { __typename login } bodyText } }
22
+ }"""
23
+ STATUS_QUERY = """
24
+ query($owner: String!, $name: String!, $number: Int!) {
25
+ rateLimit { cost remaining resetAt }
26
+ repository(owner: $owner, name: $name) { issue(number: $number) { ...S } }
27
+ }""" + FIELDS
28
+ # "Could you try the build from draft PR #1106?" points at work in progress the issue does not link.
29
+ PR_MENTION = re.compile(r"(?:\bpr\s*#|\bpull request\s*#|/pull/)(\d+)", re.I)
30
+ BATCH = 25
31
+
32
+
33
+ class LiveError(RuntimeError):
34
+ pass
35
+
36
+
37
+ def _time(text: str | None) -> datetime | None:
38
+ return datetime.fromisoformat(text.replace("Z", "+00:00")) if text else None
39
+
40
+
41
+ def _bot(login: str, typ: str | None) -> bool:
42
+ return (typ == "Bot" or login in KNOWN_BOTS or login in UNKNOWN_LOGINS or login.endswith("[bot]")
43
+ or bool(re.search(BOT_NAME_PATTERN, login) and not re.search(HUMAN_BOT_NAMES, login)))
44
+
45
+
46
+ def classify_comments(comments: list[dict], now: datetime) -> tuple[list, list, list]:
47
+ # Claims older than the claim window do not block, but a user should still see them before starting.
48
+ claims, waits, older = [], [], []
49
+ for c in comments:
50
+ login = (c.get("login") or "").lower()
51
+ if not login or _bot(login, c.get("type")):
52
+ continue
53
+ text, at = (c.get("body") or "").lower(), _time(c.get("created_at"))
54
+ item = {"by": c.get("login"), "at": c.get("created_at"), "text": (c.get("body") or "")[:160]}
55
+ if c.get("association") in INSIDER:
56
+ if re.search(WAIT_PATTERN, text):
57
+ waits.append(item)
58
+ elif re.search(CLAIM_PATTERN, text):
59
+ (claims if at and now - at <= timedelta(days=RULES.claim_days) else older).append(item)
60
+ return claims, waits, older
61
+
62
+
63
+ def _graphql_errors(body: dict) -> list[dict]:
64
+ errors = body.get("errors") or []
65
+ if any(e.get("type") == "RATE_LIMITED" or "rate limit" in (e.get("message") or "").lower() for e in errors):
66
+ raise LiveError("GitHub's rate limit is reached for this token; try again in a few minutes.")
67
+ return errors
68
+
69
+
70
+ def _from_graphql(issue: dict, repo: str) -> tuple[dict, list[dict]]:
71
+ comments = [{"login": (c.get("author") or {}).get("login"), "type": (c.get("author") or {}).get("__typename"),
72
+ "association": c.get("authorAssociation"), "body": c.get("bodyText"), "created_at": c.get("createdAt")}
73
+ for c in (issue.get("comments") or {}).get("nodes") or [] if c]
74
+ linked = [p["number"] for p in issue["closedByPullRequestsReferences"]["nodes"] if p and p["state"] == "OPEN"]
75
+ # Real data (2026-10-16): open PRs often say "related to #N" without the closing keyword GitHub links.
76
+ mentions = []
77
+ for ev in (issue.get("timelineItems") or {}).get("nodes") or []:
78
+ src = (ev or {}).get("source") or {}
79
+ author = src.get("author") or {}
80
+ if (src.get("__typename") == "PullRequest" and src.get("state") == "OPEN"
81
+ and (src.get("repository") or {}).get("nameWithOwner", "").lower() == repo.lower()
82
+ and src.get("number") not in linked and src.get("number") not in mentions
83
+ and not _bot((author.get("login") or "").lower(), author.get("__typename"))):
84
+ mentions.append(src["number"])
85
+ return ({"state": issue["state"], "title": issue["title"], "assignees": issue["assignees"]["totalCount"],
86
+ "linked_open_prs": len(linked), "open_prs_mentioning": mentions,
87
+ "labels": [lb["name"] for lb in issue["labels"]["nodes"] if lb], "updated_at": issue["updatedAt"],
88
+ "source": "github graphql"}, comments)
89
+
90
+
91
+ def _finish(status: dict, comments: list[dict], now: datetime) -> dict:
92
+ claims, waits, older = classify_comments(comments, now)
93
+ not_ready = [lb for lb in status["labels"] if re.search(WAIT_LABEL_PATTERN, norm_label(lb))]
94
+ untriaged = [lb for lb in status["labels"] if re.search(TRIAGE_LABEL_PATTERN, norm_label(lb))]
95
+ invited = has_opportunity(status["labels"])
96
+ mentioning = status.get("open_prs_mentioning") or []
97
+ reasons = [r for r, hit in (("closed", status["state"] != "OPEN"), ("assigned", status["assignees"] > 0),
98
+ ("open PR", bool(status["linked_open_prs"])),
99
+ (f"open PR mentions it ({', '.join(f'#{n}' for n in mentioning)})", bool(mentioning)),
100
+ ("claimed recently", bool(claims)), ("maintainer asked to wait", bool(waits)),
101
+ (f"label says not ready ({', '.join(not_ready)})", bool(not_ready)),
102
+ (f"label says not settled yet ({', '.join(untriaged)})", bool(untriaged) and not invited))
103
+ if hit]
104
+ mentioned = list(dict.fromkeys(int(n) for c in comments for n in PR_MENTION.findall(c.get("body") or "")))
105
+ notes = [status["note"]] if status.get("note") else []
106
+ if mentioned and not reasons:
107
+ notes.append(f"comments mention PR {', '.join(f'#{n}' for n in mentioned)}; check it is not already fixing this")
108
+ if untriaged and invited and not reasons:
109
+ notes.append(f"also labelled '{untriaged[0]}': a maintainer may not have confirmed it yet; ask before starting")
110
+ out = {**status, "claims": claims, "waits": waits, "older_claims": older, "mentioned_prs": mentioned,
111
+ "available": not reasons, "reasons": reasons, "checked_at": now.strftime("%Y-%m-%dT%H:%M:%SZ")}
112
+ if notes:
113
+ out["note"] = "; ".join(notes)
114
+ return out
115
+
116
+
117
+ class GitHubLive:
118
+ def __init__(self, token: str | None = None, client: httpx.Client | None = None):
119
+ self.token = (token or "").strip() or None
120
+ # Renamed repos and moved issues answer with redirects.
121
+ self.client = client or httpx.Client(timeout=30, follow_redirects=True)
122
+ self.headers = {"Accept": "application/vnd.github+json", "User-Agent": "mergescout"}
123
+ if self.token:
124
+ self.headers["Authorization"] = f"Bearer {self.token}"
125
+
126
+ def _get(self, path: str, params=None) -> httpx.Response:
127
+ resp = self.client.get(f"{API}{path}", headers=self.headers, params=params)
128
+ if resp.status_code == 404:
129
+ raise LiveError("GitHub says this issue or repo does not exist (or is private).")
130
+ if resp.status_code in (401, 403, 429):
131
+ raise LiveError(f"GitHub refused the live check (HTTP {resp.status_code}); set MERGESCOUT_TOKEN for more calls.")
132
+ resp.raise_for_status()
133
+ return resp
134
+
135
+ def _post(self, query: str, variables: dict) -> dict:
136
+ resp = self.client.post(f"{API}/graphql", headers=self.headers, json={"query": query, "variables": variables})
137
+ if resp.status_code == 401:
138
+ raise LiveError("GitHub rejected the token (401).")
139
+ if resp.status_code >= 400:
140
+ raise LiveError(f"GitHub could not answer the live check (HTTP {resp.status_code}).")
141
+ return resp.json()
142
+
143
+ def issue_status(self, owner: str, name: str, number: int) -> dict:
144
+ now = datetime.now(timezone.utc)
145
+ if self.token:
146
+ body = self._post(STATUS_QUERY, {"owner": owner, "name": name, "number": number})
147
+ errors = _graphql_errors(body)
148
+ issue = ((body.get("data") or {}).get("repository") or {}).get("issue")
149
+ if issue is None:
150
+ if errors and not all(e.get("type") == "NOT_FOUND" for e in errors):
151
+ raise LiveError("GitHub could not answer: " + "; ".join(e.get("message", "?") for e in errors))
152
+ raise LiveError("GitHub says this issue does not exist (or is a pull request).")
153
+ status, comments = _from_graphql(issue, f"{owner}/{name}")
154
+ else:
155
+ base = self._get(f"/repos/{owner}/{name}/issues/{number}").json()
156
+ if "pull_request" in base:
157
+ raise LiveError("That number is a pull request, not an issue.")
158
+ last = max(1, math.ceil((base.get("comments") or 0) / 100))
159
+ raw = self._get(f"/repos/{owner}/{name}/issues/{number}/comments", {"per_page": 100, "page": last}).json()
160
+ comments = [{"login": (c.get("user") or {}).get("login"), "type": (c.get("user") or {}).get("type"),
161
+ "association": c.get("author_association"), "body": c.get("body"), "created_at": c.get("created_at")}
162
+ for c in raw[-10:]]
163
+ status = {"state": base["state"].upper(), "title": base["title"], "assignees": len(base.get("assignees") or []),
164
+ "linked_open_prs": None, "labels": [lb["name"] for lb in base.get("labels") or []],
165
+ "updated_at": base.get("updated_at"), "source": "github rest",
166
+ "note": "linked PRs unknown without a token (set MERGESCOUT_TOKEN to check them)"}
167
+ return _finish(status, comments, now)
168
+
169
+ def issue_statuses(self, keys: list[tuple[str, str, int]]) -> dict[tuple[str, str, int], dict | None]:
170
+ # One GraphQL call per 25 issues, so a whole result list is checked for about one rate-limit point.
171
+ if not self.token:
172
+ raise LiveError("checking many issues at once needs MERGESCOUT_TOKEN")
173
+ now, out = datetime.now(timezone.utc), {}
174
+ for start in range(0, len(keys), BATCH):
175
+ chunk = keys[start:start + BATCH]
176
+ args = ", ".join(f"$o{n}: String!, $n{n}: String!, $k{n}: Int!" for n in range(len(chunk)))
177
+ parts = " ".join(f"i{n}: repository(owner: $o{n}, name: $n{n}) {{ issue(number: $k{n}) {{ ...S }} }}"
178
+ for n in range(len(chunk)))
179
+ variables = {}
180
+ for n, (owner, name, number) in enumerate(chunk):
181
+ variables.update({f"o{n}": owner, f"n{n}": name, f"k{n}": number})
182
+ body = self._post(f"query({args}) {{ rateLimit {{ cost remaining }} {parts} }}" + FIELDS, variables)
183
+ _graphql_errors(body)
184
+ data = body.get("data") or {}
185
+ if not data:
186
+ raise LiveError("GitHub could not answer the live check.")
187
+ for n, key in enumerate(chunk):
188
+ issue = (data.get(f"i{n}") or {}).get("issue")
189
+ out[key] = _finish(*_from_graphql(issue, f"{key[0]}/{key[1]}"), now) if issue else None
190
+ return out
191
+
192
+ def fresh_issues(self, language: str | None, since: datetime, limit: int = 20) -> list[dict]:
193
+ # Issues opened after the snapshot was built: one search call.
194
+ q = f'is:issue is:open no:assignee label:"good first issue","help wanted" created:>={since:%Y-%m-%dT%H:%M:%SZ}'
195
+ if language:
196
+ q += f' language:"{language}"' if " " in language else f" language:{language}"
197
+ items = self._get("/search/issues", {"q": q, "sort": "created", "order": "desc",
198
+ "per_page": max(1, min(limit * 3, 100))}).json().get("items") or []
199
+ return [{"repo": it["repository_url"].split("/repos/", 1)[1], "number": it["number"], "title": it["title"],
200
+ "labels": [lb["name"] for lb in it.get("labels") or []], "created_at": it["created_at"],
201
+ "url": it["html_url"], "fresh": True} for it in items]