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
+ import json
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ from mergescout.crawl.state import CrawlState
6
+
7
+ LAST_RUN = "last-run.json"
8
+ # Runs that used their whole window had points to spare; others stopped on GitHub's limits and should pause first.
9
+ FULL_RUN = {"time", "limit"}
10
+
11
+
12
+ def write_last_run(data_dir: Path, out: dict) -> None:
13
+ data_dir.mkdir(parents=True, exist_ok=True)
14
+ keep = {k: out.get(k) for k in ("repos", "points", "failed", "reason")}
15
+ (data_dir / LAST_RUN).write_text(json.dumps(keep), encoding="utf-8")
16
+
17
+
18
+ def next_step(state: CrawlState, data_dir: Path, now: datetime, after_discover: bool = False) -> str:
19
+ # The relay: GitHub's hourly schedule is best effort, so each finished run decides what starts next.
20
+ marker = data_dir / "last-discover"
21
+ discovered = marker.read_text(encoding="utf-8").strip() if marker.exists() else ""
22
+ if discovered != now.strftime("%Y-%m-%d"):
23
+ return "discover"
24
+ if state.due_count() == 0:
25
+ return "none"
26
+ if after_discover:
27
+ return "crawl"
28
+ try:
29
+ last = json.loads((data_dir / LAST_RUN).read_text(encoding="utf-8"))
30
+ except (OSError, ValueError):
31
+ return "none"
32
+ if not last.get("repos"):
33
+ return "none"
34
+ return "crawl" if last.get("reason") in FULL_RUN else "crawl-later"
@@ -0,0 +1,146 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime, timedelta
3
+
4
+ from mergescout.crawl.queries import (CLOSED_ISSUES_QUERY, ISSUE_PAGE_QUERY, PR_PAGE_QUERY, REPO_META_QUERY,
5
+ is_dep_bot, issues_by_number_query, parse_closed_issues, parse_issues, parse_prs,
6
+ parse_repo_meta, prs_by_number_query)
7
+ from mergescout.github.graphql import GraphQLError, NotFound, RateLimited, too_heavy
8
+ from mergescout.labels import HOT, OPPORTUNITY_LABELS, has_opportunity, norm_label
9
+
10
+ # Pages of 100 take 5-9 s on big repos and hit GitHub's 10 s limit under load; 50 costs the same points.
11
+ MIN_PAGE = 10
12
+ # GitHub charges by the page size asked for, so a refresh starts small: most repos change little between crawls.
13
+ REFRESH_FIRST_PAGE = 20
14
+ BY_NUMBER = 50
15
+
16
+
17
+ def cutoff(now: datetime, days: int) -> str:
18
+ return (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
19
+
20
+
21
+ @dataclass
22
+ class RepoBundle:
23
+ repo_id: int
24
+ name: str
25
+ status: str = "ok"
26
+ meta: dict | None = None
27
+ prs: list[dict] = field(default_factory=list)
28
+ issues: list[dict] = field(default_factory=list)
29
+ comments: list[dict] = field(default_factory=list)
30
+ points: int = 0
31
+ error: str | None = None
32
+ full: bool = True
33
+ deleted_issues: list[int] = field(default_factory=list)
34
+ closed_issues: list[dict] = field(default_factory=list)
35
+
36
+
37
+ def _walk(gql, query: str, variables: dict, connection: str, cap: int, page: int, bundle: RepoBundle,
38
+ stop=None, first: int | None = None) -> list[dict]:
39
+ # Pages through one connection, shrinking the page when GitHub says it is too heavy.
40
+ cursor, nodes = None, []
41
+ while len(nodes) < cap:
42
+ size = min(first, page) if first and cursor is None else page
43
+ try:
44
+ result = gql.query(query, {**variables, "cursor": cursor, "n": min(size, cap - len(nodes))})
45
+ except NotFound:
46
+ raise
47
+ except GraphQLError as exc:
48
+ if too_heavy(exc) and page > MIN_PAGE:
49
+ page = max(MIN_PAGE, page // 2)
50
+ continue
51
+ raise
52
+ bundle.points += result.cost
53
+ conn = (result.data.get("repository") or {}).get(connection) or {}
54
+ batch = [n for n in conn.get("nodes") or [] if n]
55
+ nodes.extend(batch)
56
+ info = conn.get("pageInfo") or {}
57
+ if not batch or (stop and stop(batch[-1])) or not info.get("hasNextPage") or not info.get("endCursor"):
58
+ break
59
+ cursor = info["endCursor"]
60
+ return nodes[:cap]
61
+
62
+
63
+ def fetch_repo(gql, repo_id: int, name: str, now: datetime, pr_cap: int = 300, issue_cap: int = 200,
64
+ issue_days: int = 90, page: int = 50, track: list[int] = (), track_cap: int = 200,
65
+ pr_days: int = 365, changed_since: datetime | None = None, closed_cap: int = 100,
66
+ closed_days: int = 365) -> RepoBundle:
67
+ owner, repo = name.split("/", 1)
68
+ bundle = RepoBundle(repo_id, name, full=changed_since is None)
69
+ base = {"owner": owner, "name": repo}
70
+ if changed_since is None:
71
+ # First or periodic full crawl: PRs from the last year, open issues active in the last 90 days.
72
+ pr_vars, pr_key, pr_since = {**base, "order": "CREATED_AT"}, "createdAt", cutoff(now, pr_days)
73
+ since, states = cutoff(now, issue_days), ["OPEN"]
74
+ else:
75
+ # Refresh: only PRs and issues (open or closed) that changed since the last crawl; settings wait for the
76
+ # next full crawl.
77
+ pr_since = changed_since.strftime("%Y-%m-%dT%H:%M:%SZ")
78
+ pr_vars, pr_key, since, states = {**base, "order": "UPDATED_AT"}, "updatedAt", pr_since, ["OPEN", "CLOSED"]
79
+ track = ()
80
+ first = REFRESH_FIRST_PAGE if changed_since else None
81
+ try:
82
+ if changed_since is None:
83
+ meta = gql.query(REPO_META_QUERY, base)
84
+ bundle.points += meta.cost
85
+ bundle.meta = parse_repo_meta(repo_id, meta.data)
86
+ pr_nodes = _walk(gql, PR_PAGE_QUERY, pr_vars, "pullRequests", pr_cap, page, bundle,
87
+ stop=lambda n: (n.get(pr_key) or "") < pr_since, first=first)
88
+ pr_nodes = [n for n in pr_nodes if (n.get(pr_key) or "") >= pr_since]
89
+ # Issues come newest-updated first; stop once a page reaches the cutoff.
90
+ issue_nodes = _walk(gql, ISSUE_PAGE_QUERY, {**base, "since": since, "states": states}, "issues", issue_cap,
91
+ page, bundle, stop=lambda n: (n.get("updatedAt") or "") < since, first=first)
92
+ fresh = [n for n in issue_nodes if (n.get("updatedAt") or "") >= since]
93
+ # Tracked issues missing from the open list were closed, deleted or went quiet: look them up by number.
94
+ seen = {n.get("number") for n in fresh}
95
+ missing = [n for n in track if n not in seen][:track_cap]
96
+ for i in range(0, len(missing), BY_NUMBER):
97
+ chunk = missing[i:i + BY_NUMBER]
98
+ result = gql.query(issues_by_number_query(chunk), base, partial=True)
99
+ bundle.points += result.cost
100
+ found = result.data.get("repository") or {}
101
+ for number in chunk:
102
+ node = found.get(f"i{number}")
103
+ if node:
104
+ fresh.append(node)
105
+ else:
106
+ bundle.deleted_issues.append(number)
107
+ open_labels = [lb.get("name") for n in fresh if n.get("state", "OPEN") == "OPEN"
108
+ for lb in (n.get("labels") or {}).get("nodes") or [] if lb]
109
+ if changed_since is None and closed_cap and has_opportunity(open_labels):
110
+ # Label trust needs past beginner issues and who fixed them; only full crawls of hot repos pay for it.
111
+ closed_since = cutoff(now, closed_days)
112
+ # GitHub matches label names exactly, so ask with this repo's own spellings ("good first issue :wave:").
113
+ hot_names = [n for n in (bundle.meta or {}).get("label_names") or [] if norm_label(n) in HOT]
114
+ closed = _walk(gql, CLOSED_ISSUES_QUERY, {**base, "labels": hot_names or list(OPPORTUNITY_LABELS)},
115
+ "issues", closed_cap, page, bundle,
116
+ stop=lambda n: (n.get("updatedAt") or "") < closed_since)
117
+ bundle.closed_issues = parse_closed_issues(
118
+ repo_id, [n for n in closed if (n.get("updatedAt") or "") >= closed_since])
119
+ except NotFound as exc:
120
+ bundle.status, bundle.meta, bundle.error = "gone", None, str(exc)
121
+ return bundle
122
+ bundle.prs = parse_prs(repo_id, pr_nodes)
123
+ bundle.issues, bundle.comments = parse_issues(repo_id, fresh)
124
+ add_bot_outcomes(gql, base, bundle)
125
+ return bundle
126
+
127
+
128
+ def add_bot_outcomes(gql, base: dict, bundle: RepoBundle) -> None:
129
+ """Check result and commit count of finished dependency-bot PRs, 50 per query; only these need them (upgrade outcomes)."""
130
+ rows = {r["number"]: r for r in bundle.prs if r["state"] in ("MERGED", "CLOSED")
131
+ and is_dep_bot(r["author_login"], r["head_ref"])}
132
+ numbers = sorted(rows)
133
+ for i in range(0, len(numbers), BY_NUMBER):
134
+ chunk = numbers[i:i + BY_NUMBER]
135
+ try:
136
+ result = gql.query(prs_by_number_query(chunk), base, partial=True)
137
+ except (GraphQLError, RateLimited):
138
+ # Outcomes are an extra: the PRs are kept without them and the next full crawl tries again.
139
+ return
140
+ bundle.points += result.cost
141
+ found = result.data.get("repository") or {}
142
+ for n in chunk:
143
+ commits = (found.get(f"pr{n}") or {}).get("commits") or {}
144
+ last = ((commits.get("nodes") or [{}])[-1] or {}).get("commit") or {}
145
+ rows[n]["ci_state"] = (last.get("statusCheckRollup") or {}).get("state")
146
+ rows[n]["commit_count"] = commits.get("totalCount")
@@ -0,0 +1,69 @@
1
+ from mergescout.crawl.state import CrawlState
2
+
3
+
4
+ def _iso(value) -> str | None:
5
+ if value is None or isinstance(value, str):
6
+ return value
7
+ return value.strftime("%Y-%m-%dT%H:%M:%SZ")
8
+
9
+
10
+ def issue_state_rows(issues: list[dict]) -> list[dict]:
11
+ return [{"repo_id": i["repo_id"], "number": i["number"], "state": i.get("state"),
12
+ "state_reason": i.get("state_reason"), "assignees": i.get("assignees") or 0,
13
+ "linked_open_prs": i.get("linked_open_prs") or 0, "linked_merged_prs": i.get("linked_merged_prs") or 0,
14
+ "comments": i.get("comments_total") or 0, "labels": "|".join(i.get("labels") or []),
15
+ "updated_at": _iso(i.get("updated_at"))} for i in issues]
16
+
17
+
18
+ def diff_events(before: list[dict], after: list[dict]) -> list[dict]:
19
+ prev = {(r["repo_id"], r["number"]): r for r in before}
20
+ events = []
21
+ for r in after:
22
+ p = prev.get((r["repo_id"], r["number"]))
23
+ if p is None:
24
+ continue
25
+
26
+ def add(kind, detail=None):
27
+ events.append({"repo_id": r["repo_id"], "number": r["number"], "event": kind, "detail": detail,
28
+ "observed_at": r.get("observed_at")})
29
+ if r["state"] == "DELETED":
30
+ add("gone", "deleted or transferred")
31
+ continue
32
+ if p["assignees"] == 0 and r["assignees"] > 0:
33
+ add("claimed")
34
+ if p["assignees"] > 0 and r["assignees"] == 0:
35
+ add("unclaimed")
36
+ if r["linked_open_prs"] > p["linked_open_prs"]:
37
+ add("pr_opened")
38
+ if r["linked_merged_prs"] > p["linked_merged_prs"]:
39
+ add("pr_merged")
40
+ if p["state"] == "OPEN" and r["state"] == "CLOSED":
41
+ add("closed", r.get("state_reason"))
42
+ return events
43
+
44
+
45
+ def plan_outcomes(state: CrawlState, repo_id: int, issues: list[dict], observed_at: str, since: str | None = None,
46
+ deleted: list[int] = ()) -> tuple[list[dict], list[dict], list[int]]:
47
+ # Read-only: the runner applies the new state only after the repo's rows are on disk.
48
+ before = state.latest_issue_state(repo_id)
49
+ after = [{**r, "observed_at": observed_at} for r in issue_state_rows(issues)]
50
+ gone = [{"repo_id": repo_id, "number": n, "state": "DELETED", "observed_at": observed_at} for n in deleted]
51
+ events = diff_events(before, after + gone)
52
+ # Closed, deleted and quiet-for-90-days issues stop being tracked.
53
+ forget = [r["number"] for r in after if r["state"] != "OPEN"
54
+ or (since and r["updated_at"] and r["updated_at"] < since)] + list(deleted)
55
+ keep = [r for r in after if r["number"] not in set(forget)]
56
+ return events, keep, forget
57
+
58
+
59
+ def apply_outcomes(state: CrawlState, repo_id: int, after: list[dict], forget: list[int]) -> None:
60
+ if after:
61
+ state.log_issue_state(after, after[0]["observed_at"])
62
+ state.forget_issues(repo_id, forget)
63
+
64
+
65
+ def events_for_repo(state: CrawlState, repo_id: int, issues: list[dict], observed_at: str, since: str | None = None,
66
+ deleted: list[int] = ()) -> list[dict]:
67
+ events, after, forget = plan_outcomes(state, repo_id, issues, observed_at, since, deleted)
68
+ apply_outcomes(state, repo_id, after, forget)
69
+ return events
@@ -0,0 +1,250 @@
1
+ import re
2
+
3
+ from mergescout.owners import owner_logins
4
+ from mergescout.timeutil import parse_time
5
+
6
+ RL = "rateLimit { cost remaining resetAt }"
7
+ OWNER_KEYS = ("codeowners", "codeowners2", "codeowners3", "maintainers", "maintainers2", "owners")
8
+
9
+ PR_PAGE_QUERY = f"""
10
+ query($owner: String!, $name: String!, $cursor: String, $n: Int!, $order: IssueOrderField!) {{
11
+ {RL}
12
+ repository(owner: $owner, name: $name) {{
13
+ pullRequests(first: $n, after: $cursor, orderBy: {{field: $order, direction: DESC}}) {{
14
+ pageInfo {{ hasNextPage endCursor }}
15
+ nodes {{
16
+ number title createdAt updatedAt mergedAt closedAt state headRefName additions deletions changedFiles
17
+ author {{ __typename login }} authorAssociation mergedBy {{ login }} bodyText
18
+ reviews(first: 5) {{ nodes {{ submittedAt authorAssociation author {{ __typename login }} }} }}
19
+ closingIssuesReferences(first: 5) {{ nodes {{ number }} }}
20
+ }}
21
+ }}
22
+ }}
23
+ }}
24
+ """
25
+
26
+ ISSUE_FIELDS = """
27
+ number title bodyText state stateReason createdAt updatedAt closedAt
28
+ author { login } authorAssociation
29
+ labels(first: 10) { nodes { name } }
30
+ assignees(first: 5) { totalCount }
31
+ closedByPullRequestsReferences(first: 5, includeClosedPrs: true) { nodes { number state author { login } authorAssociation } }
32
+ comments(last: 5) { totalCount nodes { databaseId createdAt author { __typename login } authorAssociation bodyText } }
33
+ """
34
+
35
+ ISSUE_PAGE_QUERY = f"""
36
+ query($owner: String!, $name: String!, $cursor: String, $n: Int!, $since: DateTime!, $states: [IssueState!]) {{
37
+ {RL}
38
+ repository(owner: $owner, name: $name) {{
39
+ issues(first: $n, after: $cursor, states: $states, filterBy: {{since: $since}},
40
+ orderBy: {{field: UPDATED_AT, direction: DESC}}) {{
41
+ pageInfo {{ hasNextPage endCursor }}
42
+ nodes {{ {ISSUE_FIELDS} }}
43
+ }}
44
+ }}
45
+ }}
46
+ """
47
+
48
+
49
+ # Dependency-update bots, by login or branch (self-hosted Renovate runs under many names).
50
+ DEP_BOT = re.compile(r"^(dependabot|renovate)", re.I)
51
+ # Titles are kept only where they carry a signal: bot bumps (package and versions) and human reverts or downgrades.
52
+ REVERT_TITLE = re.compile(r"^\s*(revert|downgrade|roll ?back)\b", re.I)
53
+
54
+
55
+ def is_dep_bot(login: str | None, head: str | None) -> bool:
56
+ return bool(DEP_BOT.match(login or "") or DEP_BOT.match(head or ""))
57
+
58
+
59
+ def prs_by_number_query(numbers: list[int]) -> str:
60
+ # One query reads the last commit's check result and the commit count of up to 50 finished bot PRs (1 point).
61
+ parts = "\n".join(f" pr{int(n)}: pullRequest(number: {int(n)}) {{ commits(last: 1) {{ totalCount "
62
+ f"nodes {{ commit {{ statusCheckRollup {{ state }} }} }} }} }}" for n in numbers)
63
+ return f"query($owner: String!, $name: String!) {{\n {RL}\n repository(owner: $owner, name: $name) {{\n{parts}\n }}\n}}"
64
+
65
+
66
+ def issues_by_number_query(numbers: list[int]) -> str:
67
+ # One query re-checks up to 50 tracked issues that dropped out of the open list.
68
+ parts = "\n".join(f" i{int(n)}: issue(number: {int(n)}) {{ {ISSUE_FIELDS} }}" for n in numbers)
69
+ return f"query($owner: String!, $name: String!) {{\n {RL}\n repository(owner: $owner, name: $name) {{\n{parts}\n }}\n}}"
70
+
71
+ REPO_META_QUERY = f"""
72
+ query($owner: String!, $name: String!) {{
73
+ {RL}
74
+ repository(owner: $owner, name: $name) {{
75
+ databaseId nameWithOwner stargazerCount forkCount pushedAt isArchived
76
+ hasIssuesEnabled hasDiscussionsEnabled hasProjectsEnabled
77
+ primaryLanguage {{ name }} licenseInfo {{ spdxId }} defaultBranchRef {{ name }}
78
+ openPrs: pullRequests(states: OPEN) {{ totalCount }} openIssues: issues(states: OPEN) {{ totalCount }}
79
+ contributing: object(expression: "HEAD:CONTRIBUTING.md") {{ oid ... on Blob {{ text }} }}
80
+ contributing2: object(expression: "HEAD:.github/CONTRIBUTING.md") {{ oid ... on Blob {{ text }} }}
81
+ contributing3: object(expression: "HEAD:docs/CONTRIBUTING.md") {{ oid ... on Blob {{ text }} }}
82
+ aipolicy: object(expression: "HEAD:AI_POLICY.md") {{ oid }}
83
+ prtemplate: object(expression: "HEAD:.github/PULL_REQUEST_TEMPLATE.md") {{ oid }}
84
+ readme: object(expression: "HEAD:README.md") {{ ... on Blob {{ text }} }}
85
+ labelNames: labels(first: 100) {{ nodes {{ name }} }}
86
+ codeowners: object(expression: "HEAD:.github/CODEOWNERS") {{ ... on Blob {{ text }} }}
87
+ codeowners2: object(expression: "HEAD:CODEOWNERS") {{ ... on Blob {{ text }} }}
88
+ codeowners3: object(expression: "HEAD:docs/CODEOWNERS") {{ ... on Blob {{ text }} }}
89
+ maintainers: object(expression: "HEAD:MAINTAINERS") {{ ... on Blob {{ text }} }}
90
+ maintainers2: object(expression: "HEAD:MAINTAINERS.md") {{ ... on Blob {{ text }} }}
91
+ owners: object(expression: "HEAD:OWNERS") {{ ... on Blob {{ text }} }}
92
+ releases(first: 100, orderBy: {{field: CREATED_AT, direction: DESC}}) {{ totalCount nodes {{ createdAt }} }}
93
+ }}
94
+ }}
95
+ """
96
+
97
+ AI_BRANCH = re.compile(r"^(copilot|codex|cursor|devin|claude)[/-]", re.I)
98
+ AI_TRAILER = re.compile(r"co-authored-by:\s*(claude|codex|copilot|cursor|devin)", re.I)
99
+
100
+
101
+ def ai_tag(pr: dict) -> str | None:
102
+ login = (pr.get("author_login") or "").lower()
103
+ if "devin" in login:
104
+ return "devin"
105
+ if pr.get("author_type") == "Bot" or login.endswith("[bot]"):
106
+ return "bot"
107
+ m = AI_BRANCH.match(pr.get("head_ref") or "")
108
+ if m:
109
+ return m.group(1).lower()
110
+ m = AI_TRAILER.search(pr.get("body") or "")
111
+ if m:
112
+ return m.group(1).lower()
113
+ return None
114
+
115
+
116
+ def parse_prs(repo_id: int, nodes: list) -> list[dict]:
117
+ rows = []
118
+ for n in nodes:
119
+ if not n:
120
+ continue
121
+ author = n.get("author") or {}
122
+ reviews = [r for r in (n.get("reviews") or {}).get("nodes") or [] if r]
123
+ review = reviews[0] if reviews else {}
124
+ # Review bots often answer first; the first person's review is the real maintainer response.
125
+ human = next((r for r in reviews if (r.get("author") or {}).get("__typename") != "Bot"), {})
126
+ row = {
127
+ "repo_id": repo_id, "number": n.get("number"),
128
+ "created_at": parse_time(n.get("createdAt")), "merged_at": parse_time(n.get("mergedAt")),
129
+ "closed_at": parse_time(n.get("closedAt")), "state": n.get("state"),
130
+ "author_login": author.get("login"), "author_type": author.get("__typename"),
131
+ "author_association": n.get("authorAssociation"), "merged_by": (n.get("mergedBy") or {}).get("login"),
132
+ "head_ref": n.get("headRefName"), "additions": n.get("additions"), "deletions": n.get("deletions"),
133
+ "changed_files": n.get("changedFiles"),
134
+ "first_review_at": parse_time(review.get("submittedAt")),
135
+ "first_review_by": (review.get("author") or {}).get("login"),
136
+ "first_review_association": review.get("authorAssociation"),
137
+ "first_human_review_at": parse_time(human.get("submittedAt")),
138
+ "first_human_review_by": (human.get("author") or {}).get("login"),
139
+ "first_human_review_association": human.get("authorAssociation"),
140
+ "closing_issue_numbers": [c["number"] for c in (n.get("closingIssuesReferences") or {}).get("nodes") or []
141
+ if c],
142
+ "ci_state": None, "commit_count": None,
143
+ }
144
+ row["ai_tag"] = ai_tag({**row, "body": n.get("bodyText") or ""})
145
+ title = n.get("title") or ""
146
+ row["title"] = title[:300] if is_dep_bot(row["author_login"], row["head_ref"]) or REVERT_TITLE.match(title) else None
147
+ rows.append(row)
148
+ return rows
149
+
150
+
151
+ def _fix_fields(refs: list[dict]) -> dict:
152
+ # The merged PR is the fix; otherwise the first linked PR shows who tried.
153
+ fix = next((p for p in refs if p.get("state") == "MERGED"), None) or (refs[0] if refs else {})
154
+ return {"fix_pr": fix.get("number"), "fix_pr_state": fix.get("state"),
155
+ "fix_author": (fix.get("author") or {}).get("login"), "fix_association": fix.get("authorAssociation")}
156
+
157
+
158
+ def parse_issues(repo_id: int, nodes: list) -> tuple[list[dict], list[dict]]:
159
+ issues, comments = [], []
160
+ for n in nodes:
161
+ if not n:
162
+ continue
163
+ linked = [p for p in (n.get("closedByPullRequestsReferences") or {}).get("nodes") or [] if p]
164
+ cm = n.get("comments") or {}
165
+ issues.append({
166
+ "repo_id": repo_id, "number": n.get("number"), "title": n.get("title"),
167
+ "body_excerpt": (n.get("bodyText") or "")[:1000], "state": n.get("state"),
168
+ "state_reason": n.get("stateReason"),
169
+ "created_at": parse_time(n.get("createdAt")), "updated_at": parse_time(n.get("updatedAt")),
170
+ "closed_at": parse_time(n.get("closedAt")), "author_login": (n.get("author") or {}).get("login"),
171
+ "author_association": n.get("authorAssociation"),
172
+ "labels": [lb["name"] for lb in (n.get("labels") or {}).get("nodes") or [] if lb and lb.get("name")],
173
+ "assignees": (n.get("assignees") or {}).get("totalCount"),
174
+ "linked_open_prs": sum(p.get("state") == "OPEN" for p in linked),
175
+ "linked_merged_prs": sum(p.get("state") == "MERGED" for p in linked),
176
+ "linked_closed_prs": sum(p.get("state") == "CLOSED" for p in linked),
177
+ **_fix_fields(linked),
178
+ "comments_total": cm.get("totalCount"),
179
+ })
180
+ for c in cm.get("nodes") or []:
181
+ if c:
182
+ comments.append({"repo_id": repo_id, "number": n.get("number"), "comment_id": c.get("databaseId"),
183
+ "created_at": parse_time(c.get("createdAt")),
184
+ "author_login": (c.get("author") or {}).get("login"),
185
+ "author_type": (c.get("author") or {}).get("__typename"),
186
+ "author_association": c.get("authorAssociation"),
187
+ "body_excerpt": (c.get("bodyText") or "")[:500]})
188
+ return issues, comments
189
+
190
+
191
+ def parse_repo_meta(repo_id: int, data: dict) -> dict:
192
+ r = data.get("repository") or {}
193
+ rel = r.get("releases") or {}
194
+ dates = [d for d in (parse_time(x.get("createdAt")) for x in rel.get("nodes") or [] if x) if d]
195
+ newest = max(dates) if dates else None
196
+ contrib = r.get("contributing") or r.get("contributing2") or r.get("contributing3") or {}
197
+ return {
198
+ "repo_id": repo_id, "name": r.get("nameWithOwner"), "stars": r.get("stargazerCount"),
199
+ "forks": r.get("forkCount"), "pushed_at": parse_time(r.get("pushedAt")),
200
+ # Backlog sizes: cheap here (one repo per query), slow in the 50-repo sweep.
201
+ "open_prs": (r.get("openPrs") or {}).get("totalCount"),
202
+ "open_issues": (r.get("openIssues") or {}).get("totalCount"),
203
+ "language": (r.get("primaryLanguage") or {}).get("name"), "archived": r.get("isArchived"),
204
+ "has_issues": r.get("hasIssuesEnabled"), "has_discussions": r.get("hasDiscussionsEnabled"),
205
+ "has_projects": r.get("hasProjectsEnabled"), "license": (r.get("licenseInfo") or {}).get("spdxId"),
206
+ "default_branch": (r.get("defaultBranchRef") or {}).get("name"),
207
+ "contributing_sha": contrib.get("oid"), "contributing_excerpt": (contrib.get("text") or "")[:4000],
208
+ "ai_policy_sha": (r.get("aipolicy") or {}).get("oid"), "pr_template_sha": (r.get("prtemplate") or {}).get("oid"),
209
+ "readme_excerpt": ((r.get("readme") or {}).get("text") or "")[:2000],
210
+ "last_release_at": newest,
211
+ "releases_12m": sum(1 for d in dates if newest and (newest - d).days <= 365),
212
+ "label_names": [lb["name"] for lb in (r.get("labelNames") or {}).get("nodes") or [] if lb and lb.get("name")],
213
+ # Logins only, never the file text: owner files can be large.
214
+ "owner_logins": sorted(owner_logins("\n".join((r.get(k) or {}).get("text") or "" for k in OWNER_KEYS))),
215
+ }
216
+
217
+
218
+ CLOSED_ISSUES_QUERY = f"""
219
+ query($owner: String!, $name: String!, $cursor: String, $n: Int!, $labels: [String!]) {{
220
+ {RL}
221
+ repository(owner: $owner, name: $name) {{
222
+ issues(first: $n, after: $cursor, states: CLOSED, labels: $labels, orderBy: {{field: UPDATED_AT, direction: DESC}}) {{
223
+ pageInfo {{ hasNextPage endCursor }}
224
+ nodes {{
225
+ number createdAt updatedAt closedAt stateReason author {{ login }} authorAssociation
226
+ labels(first: 10) {{ nodes {{ name }} }}
227
+ closedByPullRequestsReferences(first: 3, includeClosedPrs: true) {{
228
+ nodes {{ number state author {{ login }} authorAssociation }}
229
+ }}
230
+ }}
231
+ }}
232
+ }}
233
+ }}
234
+ """
235
+
236
+
237
+ def parse_closed_issues(repo_id: int, nodes: list) -> list[dict]:
238
+ rows = []
239
+ for n in nodes:
240
+ if not n:
241
+ continue
242
+ refs = [p for p in (n.get("closedByPullRequestsReferences") or {}).get("nodes") or [] if p]
243
+ rows.append({
244
+ "repo_id": repo_id, "number": n.get("number"), "created_at": parse_time(n.get("createdAt")),
245
+ "closed_at": parse_time(n.get("closedAt")), "state_reason": n.get("stateReason"),
246
+ "author_login": (n.get("author") or {}).get("login"), "author_association": n.get("authorAssociation"),
247
+ "labels": [lb["name"] for lb in (n.get("labels") or {}).get("nodes") or [] if lb and lb.get("name")],
248
+ **_fix_fields(refs),
249
+ })
250
+ return rows