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,94 @@
1
+ from datetime import datetime
2
+
3
+ import duckdb
4
+
5
+ from mergescout.signals.identity import build_people, build_pr_class
6
+ from mergescout.signals.issues import build_issue_signals
7
+ from mergescout.signals.labels import build_label_trust
8
+ from mergescout.signals.repo_stats import build_repo_stats
9
+ from mergescout.signals.rules import RULES, Rules
10
+ from mergescout.signals.walls import build_walls
11
+
12
+ EVIDENCE_VERSION = 1
13
+ TABLES = ("people", "repo_stats", "issue_signals", "label_trust", "repo_walls", "candidates")
14
+
15
+
16
+ def build_candidates(con: duckdb.DuckDBPyConnection, now: datetime, rules: Rules = RULES) -> int:
17
+ # Built in one SQL pass: a Python loop took 285 s for 69k issues, far too slow for the full corpus.
18
+ con.execute("CREATE OR REPLACE MACRO iso(x) AS strftime(timezone('UTC', x), '%Y-%m-%dT%H:%M:%SZ')")
19
+ con.execute("""
20
+ CREATE OR REPLACE TABLE candidates AS
21
+ WITH lt AS (
22
+ SELECT repo_id, list({label: label, closed_n: closed_n, known_fixes: known_fixes,
23
+ outsider_fixes: outsider_fixes, outsider_share: outsider_share,
24
+ low: outsider_low, high: outsider_high, enough: coalesce(enough, false),
25
+ window_days: $window_days, median_fix_lines: median_fix_lines,
26
+ median_fix_files: median_fix_files} ORDER BY label) AS labels,
27
+ list('not enough evidence for label trust on ''' || label || ''' (' || known_fixes
28
+ || ' fixes with a known author; need ' || $min_closed || ')' ORDER BY label)
29
+ FILTER (WHERE NOT coalesce(enough, false) AND closed_n > 0) AS label_notes
30
+ FROM label_trust GROUP BY repo_id),
31
+ wl AS (
32
+ SELECT repo_id, list({wall: wall, blocking: coalesce(blocking, false), evidence: evidence}
33
+ ORDER BY wall) AS walls
34
+ FROM repo_walls GROUP BY repo_id),
35
+ base AS (
36
+ SELECT s.*, r.name AS repo, coalesce(rs.merged, 0) AS om_merged,
37
+ coalesce(rs.merged, 0) + coalesce(rs.closed, 0) AS om_decided, rs.merge_rate, rs.merge_low,
38
+ rs.merge_high, coalesce(rs.merge_enough, false) AS om_enough,
39
+ coalesce(rs.newcomer_merged, 0) AS nm_merged, coalesce(rs.newcomer_decided, 0) AS nm_decided,
40
+ rs.newcomer_rate, rs.newcomer_low, rs.newcomer_high,
41
+ coalesce(rs.newcomer_enough, false) AS nm_enough, rs.window_days, rs.window_complete,
42
+ rs.median_review_hours, coalesce(rs.reviewed, 0) AS reviewed,
43
+ coalesce(rs.review_enough, false) AS review_enough, coalesce(rs.bot_prs, 0) AS bot_prs,
44
+ coalesce(rs.ai_prs, 0) AS ai_prs, coalesce(rs.insider_prs, 0) AS insider_prs,
45
+ coalesce(lt.labels, []) AS label_trust, coalesce(lt.label_notes, []) AS label_notes,
46
+ coalesce(wl.walls, []) AS walls
47
+ FROM issue_signals s JOIN repo_meta r USING (repo_id) LEFT JOIN repo_stats rs USING (repo_id)
48
+ LEFT JOIN lt USING (repo_id) LEFT JOIN wl USING (repo_id)
49
+ WHERE s.opportunity OR s.available)
50
+ SELECT repo_id, repo, number, title, coalesce(available, false) AS available,
51
+ coalesce(opportunity, false) AS opportunity,
52
+ to_json({
53
+ version: $version, computed_at: $computed_at,
54
+ source: {run_id: run_id, fetched_at: iso(fetched_at)},
55
+ issue: {labels: coalesce(labels, []), assignees: assignees, linked_open_prs: linked_open_prs,
56
+ dead_ends: dead_ends, interest_30d: interest_30d},
57
+ availability: {available: coalesce(available, false),
58
+ blocked_by: list_filter(string_split(coalesce(blocked_by, ''), ', '), lambda b: b <> ''),
59
+ last_claim_at: iso(last_claim_at), claimers: claimers},
60
+ repo: {
61
+ outsider_merge: {merged: om_merged, decided: om_decided, rate: merge_rate, low: merge_low,
62
+ high: merge_high, enough: om_enough, window_days: window_days,
63
+ window_complete: coalesce(window_complete, false)},
64
+ newcomer_merge: {merged: nm_merged, decided: nm_decided, rate: newcomer_rate, low: newcomer_low,
65
+ high: newcomer_high, enough: nm_enough, window_days: window_days,
66
+ window_complete: coalesce(window_complete, false)},
67
+ review_hours: {median: median_review_hours, n: reviewed, enough: review_enough},
68
+ excluded: {bot_prs: bot_prs, ai_prs: ai_prs, insider_prs: insider_prs}},
69
+ labels: label_trust, walls: walls,
70
+ notes: list_concat(list_filter([
71
+ CASE WHEN NOT om_enough THEN 'not enough evidence for outsider merge rate (' || om_decided
72
+ || ' decided PRs; need ' || $min_decided || ')' END,
73
+ CASE WHEN NOT nm_enough THEN 'not enough evidence for newcomer merge rate (' || nm_decided
74
+ || ' decided PRs; need ' || $min_decided || ')' END,
75
+ CASE WHEN window_complete = false THEN 'PR history covers only the last ' || window_days
76
+ || ' days (newest ' || $pr_cap || ' PRs)' END,
77
+ CASE WHEN NOT review_enough THEN 'not enough evidence for review time (' || reviewed
78
+ || ' maintainer reviews; need ' || $min_reviewed || ')' END], lambda n: n IS NOT NULL),
79
+ label_notes)})::VARCHAR AS evidence_json
80
+ FROM base""", {"version": EVIDENCE_VERSION, "computed_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "min_decided": rules.min_decided, "pr_cap": rules.pr_cap,
81
+ "min_reviewed": rules.min_reviewed, "min_closed": rules.min_closed,
82
+ "window_days": rules.window_days})
83
+ return con.execute("SELECT count(*) FROM candidates").fetchall()[0][0]
84
+
85
+
86
+ def build_all(con: duckdb.DuckDBPyConnection, now: datetime, rules: Rules = RULES) -> dict[str, int]:
87
+ build_people(con)
88
+ build_pr_class(con)
89
+ build_repo_stats(con, now, rules)
90
+ build_issue_signals(con, now, rules)
91
+ build_label_trust(con, now, rules)
92
+ build_walls(con)
93
+ build_candidates(con, now, rules)
94
+ return {t: con.execute(f"SELECT count(*) FROM {t}").fetchall()[0][0] for t in TABLES}
@@ -0,0 +1,43 @@
1
+ """Exploit evidence for advisories' CVEs: CISA's Known Exploited Vulnerabilities list (exploited in the wild) and FIRST's
2
+ EPSS (the chance of exploitation in the next 30 days). Both are free and need no key; EPSS answers 100 CVEs a request."""
3
+ from urllib.parse import quote
4
+
5
+ from mergescout.signals.deps_map import http_get_json
6
+
7
+ KEV_FEED = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
8
+ EPSS_API = "https://api.first.org/data/v1/epss"
9
+ EPSS_BATCH = 100
10
+
11
+
12
+ def kev_ids(get_json=http_get_json) -> set[str]:
13
+ """The CVEs CISA lists as exploited in the wild; an unreadable feed gives none (never a guess)."""
14
+ try:
15
+ feed = get_json(KEV_FEED) or {}
16
+ except Exception: # noqa: BLE001
17
+ return set()
18
+ return {str(v.get("cveID")) for v in feed.get("vulnerabilities") or [] if str(v.get("cveID", "")).startswith("CVE-")}
19
+
20
+
21
+ def epss_scores(cves: list[str], get_json=http_get_json) -> dict[str, dict]:
22
+ """{cve: {"epss", "percentile"}} for the CVEs EPSS scores; a batch that fails is left out, not guessed."""
23
+ out = {}
24
+ wanted = sorted({c for c in cves if c.startswith("CVE-")})
25
+ for n in range(0, len(wanted), EPSS_BATCH):
26
+ batch = wanted[n:n + EPSS_BATCH]
27
+ try:
28
+ got = get_json(f"{EPSS_API}?cve={quote(','.join(batch), safe=',')}&limit={EPSS_BATCH}") or {}
29
+ except Exception: # noqa: BLE001
30
+ continue
31
+ for row in got.get("data") or []:
32
+ try:
33
+ out[row["cve"]] = {"epss": round(float(row["epss"]), 5), "percentile": round(float(row["percentile"]), 5)}
34
+ except (KeyError, TypeError, ValueError):
35
+ continue
36
+ return out
37
+
38
+
39
+ def lookup(cves: list[str], get_json=http_get_json) -> dict[str, dict]:
40
+ """{cve: {"epss", "percentile", "kev"}}: the shape osv.refresh_vulns(exploits=...) takes."""
41
+ kev = kev_ids(get_json)
42
+ scores = epss_scores(cves, get_json)
43
+ return {c: {**scores.get(c, {}), "kev": c in kev} for c in cves if c in scores or c in kev}
@@ -0,0 +1,258 @@
1
+ from datetime import datetime, timedelta
2
+
3
+ import duckdb
4
+
5
+ from mergescout.bench.ci import _pct, quantiles, tier
6
+ from mergescout.bench.matched import mh_risk_difference
7
+
8
+ MIN_PRS = 10
9
+ MIN_GROUP = 50
10
+ METRICS = ("first_review_hours", "merge_hours", "outsider_merge_rate", "size_lines")
11
+ DEFINITION = ("per repo, PRs opened in the last 90 days by people (bots and AI agents left out): median hours from opening "
12
+ "to the first review by a maintainer other than the author; median hours from opening to merge; outside "
13
+ "contributors' merged share of their decided PRs; median lines added plus removed. Repos with 10+ PRs.")
14
+
15
+
16
+ def flow_benchmarks(con: duckdb.DuckDBPyConnection, now: datetime, window_days: int = 90, top_languages: int = 25) -> dict:
17
+ """Review-flow percentiles of public repos by language and size, from the crawl; groups need 50+ repos."""
18
+ rows = con.execute("""
19
+ WITH w AS (
20
+ SELECT p.*, coalesce(p.first_human_review_at, p.first_review_at) AS review_at,
21
+ coalesce(p.first_human_review_by, p.first_review_by) AS review_by
22
+ FROM pr_class p WHERE p.created_at >= $since AND p.author_class IN ('insider', 'outsider'))
23
+ SELECT w.repo_id, any_value(rm.language) AS language, any_value(rm.stars) AS stars, count(*) AS prs,
24
+ median(epoch(w.review_at - w.created_at) / 3600) FILTER (
25
+ WHERE w.review_at IS NOT NULL AND w.review_by IS DISTINCT FROM w.author_login AND rv.kind = 'insider')
26
+ AS first_review_hours,
27
+ median(epoch(w.merged_at - w.created_at) / 3600) FILTER (WHERE w.merged_at IS NOT NULL) AS merge_hours,
28
+ (count(*) FILTER (WHERE w.author_class = 'outsider' AND w.state = 'MERGED'))::DOUBLE
29
+ / nullif(count(*) FILTER (WHERE w.author_class = 'outsider' AND w.state IN ('MERGED', 'CLOSED')), 0)
30
+ AS outsider_merge_rate,
31
+ median(coalesce(w.additions, 0) + coalesce(w.deletions, 0)) AS size_lines
32
+ FROM w LEFT JOIN people rv ON rv.repo_id = w.repo_id AND rv.login = w.review_by
33
+ LEFT JOIN repo_meta rm ON rm.repo_id = w.repo_id
34
+ GROUP BY w.repo_id HAVING count(*) >= $min_prs""",
35
+ {"since": now - timedelta(days=window_days), "min_prs": MIN_PRS}).fetchall()
36
+ cols = ("repo_id", "language", "stars", "prs", *METRICS)
37
+ repos = [dict(zip(cols, r)) for r in rows]
38
+ counts = {}
39
+ for r in repos:
40
+ counts[r["language"] or "Other"] = counts.get(r["language"] or "Other", 0) + 1
41
+ langs = {name for name, _ in sorted(counts.items(), key=lambda kv: -kv[1])[:top_languages]}
42
+ groups = {}
43
+ for r in repos:
44
+ lang = r["language"] if r["language"] in langs else "Other"
45
+ for key in (f"{lang}|{tier(r['stars'])}", f"{lang}|all", f"all|{tier(r['stars'])}", "all|all"):
46
+ groups.setdefault(key, []).append(r)
47
+ out = {}
48
+ for key, items in groups.items():
49
+ if len(items) < MIN_GROUP:
50
+ continue
51
+ entry = {"repos": len(items)}
52
+ for m in METRICS:
53
+ values = [round(float(r[m]), 4) for r in items if r[m] is not None]
54
+ if len(values) >= MIN_GROUP:
55
+ entry[m] = {"repos": len(values), **quantiles(values)}
56
+ out[key] = entry
57
+ return {"version": 1, "built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "window_days": window_days,
58
+ "repos": len(repos), "definition": DEFINITION, "population": 'public GitHub repos in the daily crawl with 10+ pull requests by people in the last 90 days', "gaps": 'no private or enterprise teams; GitHub only; review counted from opening, not from leaving draft', "groups": out}
59
+
60
+
61
+ def _median(values: list[float]) -> float | None:
62
+ s, n = sorted(values), len(values)
63
+ return None if not n else (s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2)
64
+
65
+
66
+ BUCKETS = (("5-15", 5, 15), ("15-50", 15, 50), ("50+", 50, None))
67
+
68
+
69
+ def odds_backtest(con: duckdb.DuckDBPyConnection, now: datetime, horizon_days: int = 30, settle_days: int = 60,
70
+ history_days: int = 365, k: int = 10, min_past: int = 5, min_future: int = 3) -> dict:
71
+ """How well a repo's past outside-PR merge rate predicts its next outside PRs: the raw rate, the rate pulled toward
72
+ the typical repo of its group by k PRs (what the suite shows), and that typical rate alone. The target is every
73
+ outside PR opened in the next horizon_days, merged within the settle window or not; errors weighted by PRs and
74
+ also given by how many past PRs a repo had."""
75
+ cut = now - timedelta(days=horizon_days + settle_days)
76
+ rows = con.execute("""
77
+ SELECT p.repo_id, any_value(rm.language), any_value(rm.stars),
78
+ count(*) FILTER (WHERE p.closed_at >= $lo AND p.closed_at < $cut AND p.state IN ('MERGED', 'CLOSED')),
79
+ count(*) FILTER (WHERE p.closed_at >= $lo AND p.closed_at < $cut AND p.state = 'MERGED'),
80
+ count(*) FILTER (WHERE p.created_at >= $cut AND p.created_at < $hi),
81
+ count(*) FILTER (WHERE p.created_at >= $cut AND p.created_at < $hi AND p.merged_at IS NOT NULL AND p.merged_at < $settled)
82
+ FROM pr_class p LEFT JOIN repo_meta rm USING (repo_id) WHERE p.author_class = 'outsider' GROUP BY p.repo_id""",
83
+ {"lo": cut - timedelta(days=history_days), "cut": cut, "hi": cut + timedelta(days=horizon_days),
84
+ "settled": cut + timedelta(days=horizon_days + settle_days)}).fetchall()
85
+ repos = [(lang or "Other", tier(stars), pc, pm, fn, fm) for _, lang, stars, pc, pm, fn, fm in rows
86
+ if pc >= min_past and fn >= min_future]
87
+ out = {"version": 2, "repos": len(repos), "prs": sum(r[4] for r in repos), "k": k,
88
+ "definition": f"outside PRs decided in the {history_days} days before a cut-off predict the share of outside PRs "
89
+ f"opened in the next {horizon_days} days that were merged within {horizon_days + settle_days} days "
90
+ f"of the cut-off; repos with {min_past}+ past and {min_future}+ next PRs; the typical rate is the "
91
+ f"median of its language and size group (50+ repos) or of all repos"}
92
+ if not repos:
93
+ return out
94
+ by_group = {}
95
+ for lang, size, pc, pm, _, _ in repos:
96
+ for key in (f"{lang}|{size}", "all|all"):
97
+ by_group.setdefault(key, []).append(pm / pc)
98
+ typical = {key: _median(v) for key, v in by_group.items() if len(v) >= 50 or key == "all|all"}
99
+ err, bins, buckets = {"raw": 0.0, "shrunk": 0.0, "typical_only": 0.0}, {}, {}
100
+ for lang, size, pc, pm, fn, fm in repos:
101
+ typ = typical.get(f"{lang}|{size}", typical["all|all"])
102
+ observed, shrunk = fm / fn, (pm + k * typ) / (pc + k)
103
+ name = next(label for label, lo, hi in BUCKETS if pc >= lo and (hi is None or pc < hi))
104
+ b = buckets.setdefault(name, {"repos": 0, "prs": 0, "err": {"raw": 0.0, "shrunk": 0.0, "typical_only": 0.0}})
105
+ b["repos"] += 1
106
+ b["prs"] += fn
107
+ for key, pred in (("raw", pm / pc), ("shrunk", shrunk), ("typical_only", typ)):
108
+ err[key] += abs(pred - observed) * fn
109
+ b["err"][key] += abs(pred - observed) * fn
110
+ cal = bins.setdefault(min(int(shrunk * 10), 9), [0, 0.0, 0])
111
+ cal[0] += fn
112
+ cal[1] += shrunk * fn
113
+ cal[2] += fm
114
+ total = out["prs"]
115
+
116
+ def table(items):
117
+ # The share of next PRs merged, by the repo's raw past rate in tenths.
118
+ cells = {}
119
+ for _, _, pc, pm, fn, fm in items:
120
+ c = cells.setdefault(min(int(pm / pc * 10), 9), [0, 0.0, 0])
121
+ c[0] += fn
122
+ c[1] += pm / pc * fn
123
+ c[2] += fm
124
+ return cells
125
+ # The correction is measured on repos it was not built from: fit on one half, check on the other, and swap.
126
+ halves = (repos[0::2], repos[1::2])
127
+ err["calibrated"] = 0.0
128
+ for fit, check in ((halves[0], halves[1]), (halves[1], halves[0])):
129
+ cells = table(fit)
130
+ for _, _, pc, pm, fn, fm in check:
131
+ c = cells.get(min(int(pm / pc * 10), 9))
132
+ err["calibrated"] += abs((c[2] / c[0] if c else pm / pc) - fm / fn) * fn
133
+ out["calibration_raw"] = [{"bin": f"{i / 10:.1f}-{(i + 1) / 10:.1f}", "prs": n, "predicted": round(s / n, 4),
134
+ "observed": round(m / n, 4)} for i, (n, s, m) in sorted(table(repos).items())]
135
+ out.update(typical=round(typical["all|all"], 4), mae={name: round(e / total, 4) for name, e in err.items()},
136
+ by_past_prs={label: ({"repos": buckets[label]["repos"], "prs": buckets[label]["prs"],
137
+ "mae": {key: round(e / buckets[label]["prs"], 4) for key, e in buckets[label]["err"].items()}}
138
+ if label in buckets else {"repos": 0, "prs": 0}) for label, _, _ in BUCKETS},
139
+ calibration=[{"bin": f"{i / 10:.1f}-{(i + 1) / 10:.1f}", "prs": n, "predicted": round(s / n, 4),
140
+ "observed": round(m / n, 4)} for i, (n, s, m) in sorted(bins.items())])
141
+ return out
142
+
143
+
144
+ MIN_AGENT_REPOS = 20
145
+
146
+
147
+ def agent_prs(con: duckdb.DuckDBPyConnection, now: datetime, window_days: int = 180, min_each: int = 5) -> dict:
148
+ """PRs opened by coding agents next to people's outside PRs in the same repos (each with min_each+ decided PRs of
149
+ both kinds): the median repo's merge share of each, per group of similar repos with 20+ such repos. Agents are known
150
+ by their account, as the suite's app knows them (an AI-assisted PR by a person stays that person's), and a PR counts
151
+ in the window it was decided in."""
152
+ rows = con.execute("""
153
+ SELECT p.repo_id, any_value(rm.language), any_value(rm.stars),
154
+ count(*) FILTER (WHERE p.author_role = 'agent'), count(*) FILTER (WHERE p.author_role = 'agent' AND p.state = 'MERGED'),
155
+ count(*) FILTER (WHERE p.author_role = 'outsider'),
156
+ count(*) FILTER (WHERE p.author_role = 'outsider' AND p.state = 'MERGED')
157
+ FROM pr_class p LEFT JOIN repo_meta rm USING (repo_id)
158
+ WHERE coalesce(p.merged_at, p.closed_at) >= $since AND p.state IN ('MERGED', 'CLOSED')
159
+ AND p.author_role IN ('agent', 'outsider')
160
+ GROUP BY p.repo_id""", {"since": now - timedelta(days=window_days)}).fetchall()
161
+ groups = {}
162
+ for _, lang, stars, an, am, on, om in rows:
163
+ if an < min_each or on < min_each:
164
+ continue
165
+ lang = lang or "Other"
166
+ for key in (f"{lang}|{tier(stars)}", f"{lang}|all", f"all|{tier(stars)}", "all|all"):
167
+ groups.setdefault(key, []).append((an, am / an, on, om / on))
168
+ return {key: {"repos_with_agent_prs": len(g),
169
+ "agent": {"decided": sum(x[0] for x in g), "merged": round(_median([x[1] for x in g]), 4)},
170
+ "outsider": {"decided": sum(x[2] for x in g), "merged": round(_median([x[3] for x in g]), 4)}}
171
+ for key, g in groups.items() if len(g) >= MIN_AGENT_REPOS}
172
+
173
+
174
+ MIN_LEADER_REPOS = 10 # an agent seen in fewer repos is left off the leaderboard
175
+ # Which agent, from its account's name or the branch prefix it pushes from a person's account.
176
+ AGENT_NAME = """CASE
177
+ WHEN p.author_role = 'agent' THEN coalesce(
178
+ nullif(regexp_extract(lower(p.author_login), '(copilot|devin|claude|codex|cursor|openhands|sweep|jules|codegen)', 1), ''),
179
+ 'other agent')
180
+ ELSE lower(regexp_extract(coalesce(p.head_ref, ''), '^(copilot|codex|cursor|devin|claude)[/-]', 1)) END"""
181
+
182
+
183
+ def agent_leaderboard(con: duckdb.DuckDBPyConnection, now: datetime, window_days: int = 180) -> dict:
184
+ """Each coding agent across the crawl's public repos: repos and decided PRs, the share merged, the share merged by
185
+ someone other than its author, and its gap against people's PRs of the same repo, month and size (Mantel-Haenszel,
186
+ 95% range); agents seen in fewer than 10 repos are left out."""
187
+ rows = con.execute(f"""
188
+ SELECT p.repo_id, strftime(coalesce(p.merged_at, p.closed_at), '%Y-%m') AS month,
189
+ CAST(floor(log2(coalesce(p.additions, 0) + coalesce(p.deletions, 0) + 1)) AS INTEGER) AS band,
190
+ CASE WHEN p.author_role = 'agent' OR regexp_matches(coalesce(p.head_ref, ''), '^(copilot|codex|cursor|devin|claude)[/-]', 'i')
191
+ THEN {AGENT_NAME} END AS agent,
192
+ count(*), count(*) FILTER (WHERE p.state = 'MERGED'),
193
+ count(*) FILTER (WHERE p.state = 'MERGED' AND lower(coalesce(p.merged_by, '')) <> lower(coalesce(p.author_login, '')))
194
+ FROM pr_class p
195
+ WHERE coalesce(p.merged_at, p.closed_at) >= $since AND p.state IN ('MERGED', 'CLOSED')
196
+ AND p.author_role IN ('agent', 'insider', 'outsider')
197
+ GROUP BY p.repo_id, month, band, agent""", {"since": now - timedelta(days=window_days)}).fetchall()
198
+ people, mine = {}, {}
199
+ for repo, month, band, agent, n, merged, others in rows:
200
+ if agent:
201
+ mine.setdefault(agent, {})[(repo, month, band)] = (n, merged, others)
202
+ else:
203
+ people[(repo, month, band)] = (n, merged)
204
+ agents = {}
205
+ for name, cells in mine.items():
206
+ repos = {k[0] for k in cells}
207
+ if len(repos) < MIN_LEADER_REPOS:
208
+ continue
209
+ n, merged, others = (sum(c[i] for c in cells.values()) for i in range(3))
210
+ pairs = [(c[1], c[0], people[k][1], people[k][0]) for k, c in cells.items() if k in people and people[k][0]]
211
+ entry = {"repos": len(repos), "prs": n, "merged": round(merged / n, 4), "merged_by_others": round(others / n, 4)}
212
+ if pairs:
213
+ rd, se = mh_risk_difference(pairs)
214
+ entry["vs_people"] = {"gap": round(rd, 4), "low": round(rd - 1.96 * se, 4), "high": round(rd + 1.96 * se, 4),
215
+ "prs_compared": sum(p[1] for p in pairs)}
216
+ agents[name] = entry
217
+ return {"window_days": window_days, "agents": dict(sorted(agents.items(), key=lambda kv: -kv[1]["prs"]))}
218
+
219
+
220
+ def agent_matched(con: duckdb.DuckDBPyConnection, now: datetime, window_days: int = 180) -> dict:
221
+ """Coding agents' PRs (their account, or an agent's branch such as codex/ opened from a person's account) against
222
+ people's PRs of the same repo, month and size, per group of similar repos with 20+ such repos: the gap in merged and
223
+ in merged by someone other than the author (Mantel-Haenszel risk difference, 95% range). Most agents' PRs are merged
224
+ by whoever ran the agent, so the second gap is the one that says whether anyone else accepted them."""
225
+ rows = con.execute("""
226
+ SELECT p.repo_id, any_value(rm.language), any_value(rm.stars),
227
+ strftime(coalesce(p.merged_at, p.closed_at), '%Y-%m') AS month,
228
+ CAST(floor(log2(coalesce(p.additions, 0) + coalesce(p.deletions, 0) + 1)) AS INTEGER) AS band,
229
+ (p.author_role = 'agent' OR regexp_matches(coalesce(p.head_ref, ''), '^(copilot|codex|cursor|devin|claude)[/-]', 'i')) AS agent,
230
+ count(*), count(*) FILTER (WHERE p.state = 'MERGED'),
231
+ count(*) FILTER (WHERE p.state = 'MERGED' AND lower(coalesce(p.merged_by, '')) <> lower(coalesce(p.author_login, '')))
232
+ FROM pr_class p LEFT JOIN repo_meta rm USING (repo_id)
233
+ WHERE coalesce(p.merged_at, p.closed_at) >= $since AND p.state IN ('MERGED', 'CLOSED')
234
+ AND p.author_role IN ('agent', 'insider', 'outsider')
235
+ GROUP BY p.repo_id, month, band, agent""", {"since": now - timedelta(days=window_days)}).fetchall()
236
+ strata, meta = {}, {}
237
+ for repo, lang, stars, month, band, agent, n, merged, others in rows:
238
+ cell = strata.setdefault((repo, month, band), [0, 0, 0, 0, 0, 0])
239
+ at = 0 if agent else 3
240
+ cell[at], cell[at + 1], cell[at + 2] = n, merged, others
241
+ meta[repo] = (lang or "Other", stars)
242
+ groups = {}
243
+ for (repo, _month, _band), c in strata.items():
244
+ if c[0] and c[3]:
245
+ lang, stars = meta[repo]
246
+ for key in (f"{lang}|{tier(stars)}", f"{lang}|all", f"all|{tier(stars)}", "all|all"):
247
+ groups.setdefault(key, []).append((repo, c))
248
+ out = {}
249
+ for key, cells in groups.items():
250
+ repos = {repo for repo, _c in cells}
251
+ if len(repos) < MIN_AGENT_REPOS:
252
+ continue
253
+ found = {"repos": len(repos), "agent_prs": sum(c[0] for _r, c in cells)}
254
+ for name, hit in (("merged", 1), ("merged_by_others", 2)):
255
+ rd, se = mh_risk_difference([(c[hit], c[0], c[3 + hit], c[3]) for _r, c in cells])
256
+ found[name] = {"gap": round(rd, 4), "low": round(rd - 1.96 * se, 4), "high": round(rd + 1.96 * se, 4)}
257
+ out[key] = found
258
+ return out
@@ -0,0 +1,73 @@
1
+ import duckdb
2
+
3
+ from mergescout.signals.rules import (AI_AGENT_BOT_NAMES, AI_AGENT_LOGINS, BOT_NAME_PATTERN, HUMAN_BOT_NAMES,
4
+ KNOWN_BOTS, UNKNOWN_LOGINS)
5
+
6
+
7
+ def _quoted(names) -> str:
8
+ return ", ".join("'" + n.replace("'", "''") + "'" for n in sorted(names))
9
+
10
+
11
+ def build_people(con: duckdb.DuckDBPyConnection) -> None:
12
+ # Insiders are found by what they do (merging, listed roles), not only by GitHub's association label.
13
+ con.execute(f"""
14
+ CREATE OR REPLACE TABLE people AS
15
+ WITH acts AS (
16
+ SELECT repo_id, author_login AS login, author_association AS assoc, author_type AS typ, 'pr' AS act FROM prs
17
+ UNION ALL SELECT repo_id, merged_by, NULL, NULL, 'merged_other' FROM prs
18
+ WHERE state = 'MERGED' AND merged_by IS NOT NULL AND merged_by IS DISTINCT FROM author_login
19
+ UNION ALL SELECT repo_id, merged_by, NULL, NULL, 'self_merge' FROM prs
20
+ WHERE state = 'MERGED' AND merged_by = author_login
21
+ UNION ALL SELECT repo_id, first_review_by, first_review_association, NULL, 'review' FROM prs
22
+ UNION ALL SELECT repo_id, first_human_review_by, first_human_review_association, NULL, 'review' FROM prs
23
+ UNION ALL SELECT repo_id, author_login, author_association, NULL, 'issue' FROM issues
24
+ UNION ALL SELECT repo_id, author_login, author_association, author_type, 'comment' FROM comments
25
+ UNION ALL SELECT repo_id, author_login, author_association, NULL, 'issue' FROM closed_issues),
26
+ agg AS (
27
+ SELECT repo_id, login,
28
+ bool_or(typ = 'Bot' OR lower(login) LIKE '%[bot]' OR lower(login) IN ({_quoted(KNOWN_BOTS)})
29
+ OR (regexp_matches(lower(login), '{BOT_NAME_PATTERN}')
30
+ AND NOT regexp_matches(lower(login), '{HUMAN_BOT_NAMES}'))) AS bot,
31
+ bool_or(assoc IN ('OWNER', 'MEMBER', 'COLLABORATOR')) AS listed,
32
+ bool_or(act = 'merged_other') AS merges_others,
33
+ bool_or(act = 'self_merge') AS self_merges,
34
+ count(*) AS activity,
35
+ count(*) FILTER (WHERE act = 'pr') AS prs
36
+ FROM acts
37
+ WHERE login IS NOT NULL AND lower(login) NOT IN ({_quoted(UNKNOWN_LOGINS)})
38
+ GROUP BY repo_id, login),
39
+ -- People named in CODEOWNERS / MAINTAINERS / OWNERS are maintainers even when they rarely merge.
40
+ owners AS (
41
+ SELECT DISTINCT repo_id, lower(o) AS l FROM (SELECT repo_id, unnest(owner_logins) AS o FROM repo_meta))
42
+ SELECT a.repo_id, a.login,
43
+ CASE WHEN bot THEN 'bot' WHEN listed OR merges_others OR self_merges OR ow.l IS NOT NULL THEN 'insider'
44
+ ELSE 'outsider' END AS kind,
45
+ concat_ws(', ', CASE WHEN bot THEN 'bot account' END, CASE WHEN listed THEN 'listed role' END,
46
+ CASE WHEN merges_others THEN 'merges others' END, CASE WHEN self_merges THEN 'merges own' END,
47
+ CASE WHEN ow.l IS NOT NULL THEN 'in owner files' END) AS reasons,
48
+ activity, prs
49
+ FROM agg a LEFT JOIN owners ow ON ow.repo_id = a.repo_id AND ow.l = lower(a.login)""")
50
+
51
+
52
+ def build_pr_class(con: duckdb.DuckDBPyConnection) -> None:
53
+ con.execute(f"""
54
+ CREATE OR REPLACE VIEW pr_class AS
55
+ SELECT p.*, CASE
56
+ WHEN p.author_login IS NULL OR lower(p.author_login) IN ({_quoted(UNKNOWN_LOGINS)}) THEN 'unknown'
57
+ WHEN replace(lower(p.author_login), '[bot]', '') IN ({_quoted(AI_AGENT_LOGINS)}) THEN 'ai'
58
+ WHEN replace(lower(p.author_login), '[bot]', '') IN ({_quoted(AI_AGENT_BOT_NAMES)})
59
+ AND (p.author_type = 'Bot' OR lower(p.author_login) LIKE '%[bot]') THEN 'ai'
60
+ WHEN pe.kind = 'bot' OR p.ai_tag = 'bot' THEN 'bot'
61
+ WHEN p.ai_tag IS NOT NULL THEN 'ai'
62
+ WHEN pe.kind = 'insider' THEN 'insider'
63
+ ELSE 'outsider' END AS author_class,
64
+ -- The author alone, as the suite's app decides it: a person's AI-assisted PR is still that person's.
65
+ CASE
66
+ WHEN p.author_login IS NULL OR lower(p.author_login) IN ({_quoted(UNKNOWN_LOGINS)}) THEN 'unknown'
67
+ WHEN replace(lower(p.author_login), '[bot]', '') IN ({_quoted(AI_AGENT_LOGINS)}) THEN 'agent'
68
+ WHEN replace(lower(p.author_login), '[bot]', '') IN ({_quoted(AI_AGENT_BOT_NAMES)})
69
+ AND (p.author_type = 'Bot' OR lower(p.author_login) LIKE '%[bot]') THEN 'agent'
70
+ WHEN pe.kind = 'bot' OR p.ai_tag = 'bot' THEN 'bot'
71
+ WHEN pe.kind = 'insider' THEN 'insider'
72
+ ELSE 'outsider' END AS author_role
73
+ FROM prs p LEFT JOIN people pe ON pe.repo_id = p.repo_id AND pe.login = p.author_login""")
@@ -0,0 +1,76 @@
1
+ from datetime import datetime, timedelta
2
+
3
+ import duckdb
4
+
5
+ from mergescout.labels import BEGINNER, HOT
6
+ from mergescout.signals.identity import _quoted
7
+ from mergescout.signals.rules import (CLAIM_PATTERN, DISCUSSION_LABEL, DISCUSSION_TITLE, RULES, TRIAGE_LABEL_PATTERN,
8
+ UNKNOWN_LOGINS, WAIT_LABEL_PATTERN, WAIT_PATTERN, Rules)
9
+
10
+
11
+ def norm_label_macro(con: duckdb.DuckDBPyConnection) -> None:
12
+ # Same rule as labels.norm_label: lower case, drop :emoji:, keep letters and digits, single spaces.
13
+ con.execute(r"""CREATE OR REPLACE MACRO norm_label(x) AS trim(regexp_replace(
14
+ regexp_replace(regexp_replace(lower(x), ':[a-z0-9_+-]+:', ' ', 'g'), '[^a-z0-9]+', ' ', 'g'), ' +', ' ', 'g'))""")
15
+
16
+
17
+ def build_issue_signals(con: duckdb.DuckDBPyConnection, now: datetime, rules: Rules = RULES) -> None:
18
+ norm_label_macro(con)
19
+ claim_since = now - timedelta(days=rules.claim_days)
20
+ interest_since = now - timedelta(days=rules.interest_days)
21
+ con.execute(f"""
22
+ CREATE OR REPLACE TABLE issue_signals AS
23
+ WITH c AS (
24
+ SELECT c.*, coalesce(pe.kind, 'outsider') AS kind FROM comments c
25
+ LEFT JOIN people pe ON pe.repo_id = c.repo_id AND pe.login = c.author_login
26
+ WHERE c.author_login IS NOT NULL AND lower(c.author_login) NOT IN ({_quoted(UNKNOWN_LOGINS)})),
27
+ claims AS (
28
+ SELECT repo_id, number, max(created_at) AS last_claim_at, count(DISTINCT author_login) AS claimers
29
+ FROM c WHERE kind = 'outsider' AND regexp_matches(lower(body_excerpt), $claim) GROUP BY ALL),
30
+ waits AS (
31
+ SELECT repo_id, number, max(created_at) AS wait_at
32
+ FROM c WHERE kind = 'insider' AND regexp_matches(lower(body_excerpt), $wait) GROUP BY ALL),
33
+ interest AS (
34
+ SELECT repo_id, number, count(DISTINCT author_login) AS interest_30d
35
+ FROM c WHERE kind = 'outsider' AND created_at >= $interest_since GROUP BY ALL),
36
+ il AS (
37
+ SELECT *, list_transform(coalesce(labels, []), lambda x: norm_label(x)) AS nl FROM issues),
38
+ i AS (
39
+ SELECT *, len(list_filter(nl, lambda x: regexp_matches(x, $wait_label))) > 0 AS wait_label,
40
+ len(list_filter(nl, lambda x: regexp_matches(x, $triage_label))) > 0 AS triage_label,
41
+ list_has_any(nl, $hot) AS opportunity
42
+ FROM il)
43
+ SELECT i.repo_id, i.number, i.title, i.labels,
44
+ i.opportunity, list_has_any(i.nl, $beginner) AS beginner, i.triage_label,
45
+ CASE WHEN regexp_matches(lower(coalesce(i.title, '')), $disc_title)
46
+ OR len(list_filter(i.nl, lambda x: regexp_matches(x, $disc_label))) > 0
47
+ THEN 'discussion' ELSE 'task' END AS kind,
48
+ coalesce(i.assignees, 0) AS assignees, coalesce(i.linked_open_prs, 0) AS linked_open_prs,
49
+ coalesce(i.linked_closed_prs, 0) AS dead_ends,
50
+ cl.last_claim_at, coalesce(cl.claimers, 0) AS claimers, w.wait_at,
51
+ coalesce(it.interest_30d, 0) AS interest_30d,
52
+ (coalesce(i.assignees, 0) = 0 AND coalesce(i.linked_open_prs, 0) = 0
53
+ AND (cl.last_claim_at IS NULL OR cl.last_claim_at < $claim_since) AND w.wait_at IS NULL
54
+ AND NOT i.wait_label AND NOT (i.triage_label AND NOT i.opportunity)) AS available,
55
+ concat_ws(', ', CASE WHEN i.assignees > 0 THEN 'assigned' END,
56
+ CASE WHEN i.linked_open_prs > 0 THEN 'open PR' END,
57
+ CASE WHEN cl.last_claim_at >= $claim_since THEN 'claimed recently' END,
58
+ CASE WHEN w.wait_at IS NOT NULL THEN 'maintainer asked to wait' END,
59
+ CASE WHEN i.wait_label THEN 'label says not ready' END,
60
+ CASE WHEN i.triage_label AND NOT i.opportunity THEN 'label says not settled yet' END)
61
+ AS blocked_by,
62
+ i.created_at, i.updated_at, i.fetched_at, i.run_id
63
+ FROM i
64
+ LEFT JOIN claims cl USING (repo_id, number) LEFT JOIN waits w USING (repo_id, number)
65
+ LEFT JOIN interest it USING (repo_id, number)
66
+ -- Bot-made issues (CI failures, CVE alerts) are not tasks unless a beginner or help label invites outsiders:
67
+ -- real bots also mirror maintainer tasks (stdlib-bot lint fixes, MelvinBot "Help Wanted").
68
+ LEFT JOIN people au ON au.repo_id = i.repo_id AND au.login = i.author_login
69
+ WHERE i.state = 'OPEN' AND NOT (coalesce(au.kind, '') = 'bot' AND NOT list_has_any(i.nl, $hot))
70
+ AND NOT regexp_matches(lower(coalesce(i.title, '')), $dashboard) AND NOT EXISTS (
71
+ SELECT 1 FROM issue_events e WHERE e.repo_id = i.repo_id AND e.number = i.number AND e.event = 'gone'
72
+ AND e.observed_at::DATE >= coalesce(i.fetched_at::DATE, DATE '1970-01-01'))""",
73
+ {"claim": CLAIM_PATTERN, "wait": WAIT_PATTERN, "interest_since": interest_since,
74
+ "claim_since": claim_since, "hot": sorted(HOT), "beginner": sorted(BEGINNER),
75
+ "wait_label": WAIT_LABEL_PATTERN, "triage_label": TRIAGE_LABEL_PATTERN, "disc_title": DISCUSSION_TITLE,
76
+ "disc_label": DISCUSSION_LABEL, "dashboard": r"^\s*dependency dashboard\s*$"})
@@ -0,0 +1,79 @@
1
+ from datetime import datetime, timedelta
2
+
3
+ import duckdb
4
+
5
+ from mergescout.labels import HOT
6
+ from mergescout.signals.issues import norm_label_macro
7
+ from mergescout.signals.repo_stats import _wilson_macros
8
+ from mergescout.signals.rules import RULES, Rules
9
+
10
+
11
+ def build_label_trust(con: duckdb.DuckDBPyConnection, now: datetime, rules: Rules = RULES) -> None:
12
+ norm_label_macro(con)
13
+ _wilson_macros(con)
14
+ con.execute("""
15
+ CREATE OR REPLACE TABLE label_trust AS
16
+ WITH closed AS (
17
+ -- Refreshes bring closed issues as issue rows; only crawlers that record fixes fill linked_closed_prs.
18
+ SELECT DISTINCT ON (repo_id, number) *
19
+ FROM (SELECT repo_id, number, labels, state_reason, fix_pr, fix_pr_state, fix_author, fix_association,
20
+ closed_at, fetched_at FROM closed_issues
21
+ UNION ALL
22
+ SELECT repo_id, number, labels, state_reason, fix_pr, fix_pr_state, fix_author, fix_association,
23
+ closed_at, fetched_at FROM issues WHERE state = 'CLOSED' AND linked_closed_prs IS NOT NULL)
24
+ WHERE closed_at >= $since
25
+ ORDER BY repo_id, number, fetched_at DESC NULLS LAST),
26
+ ci AS (
27
+ SELECT DISTINCT ON (repo_id, number, label) *
28
+ FROM (SELECT *, unnest(list_transform(labels, lambda x: norm_label(x))) AS label FROM closed)
29
+ WHERE list_contains($hot, label)),
30
+ judged AS (
31
+ SELECT ci.repo_id, ci.label, ci.number, ci.fix_pr, CASE
32
+ WHEN ci.state_reason = 'NOT_PLANNED' THEN 'not_planned'
33
+ WHEN ci.state_reason = 'DUPLICATE' THEN 'duplicate'
34
+ WHEN ci.fix_pr_state IS DISTINCT FROM 'MERGED' THEN 'other'
35
+ WHEN pe.kind = 'bot' OR p.ai_tag = 'bot' THEN 'bot'
36
+ WHEN p.ai_tag IS NOT NULL THEN 'ai'
37
+ WHEN pe.kind = 'insider' OR ci.fix_association IN ('OWNER', 'MEMBER', 'COLLABORATOR') THEN 'insider'
38
+ WHEN ci.fix_author IS NULL THEN 'other'
39
+ ELSE 'outsider' END AS outcome
40
+ FROM ci
41
+ LEFT JOIN people pe ON pe.repo_id = ci.repo_id AND pe.login = ci.fix_author
42
+ LEFT JOIN prs p ON p.repo_id = ci.repo_id AND p.number = ci.fix_pr),
43
+ sizes AS (
44
+ SELECT j.repo_id, j.label, median(p.additions + p.deletions) AS median_fix_lines,
45
+ median(p.changed_files) AS median_fix_files, count(p.number) AS sized
46
+ FROM judged j JOIN prs p ON p.repo_id = j.repo_id AND p.number = j.fix_pr
47
+ WHERE j.outcome IN ('outsider', 'insider') GROUP BY ALL),
48
+ stale AS (
49
+ SELECT repo_id, label, count(*) AS open_stale
50
+ FROM (SELECT repo_id, created_at, unnest(list_transform(labels, lambda x: norm_label(x))) AS label
51
+ FROM issues WHERE state = 'OPEN')
52
+ WHERE list_contains($hot, label) AND created_at < $stale_before GROUP BY ALL),
53
+ counts AS (
54
+ SELECT repo_id, label, count(*) AS closed_n,
55
+ count(*) FILTER (WHERE outcome = 'outsider') AS outsider_fixes,
56
+ count(*) FILTER (WHERE outcome = 'insider') AS insider_fixes,
57
+ count(*) FILTER (WHERE outcome = 'ai') AS ai_fixes,
58
+ count(*) FILTER (WHERE outcome = 'bot') AS bot_fixes,
59
+ count(*) FILTER (WHERE outcome = 'not_planned') AS not_planned,
60
+ count(*) FILTER (WHERE outcome = 'duplicate') AS duplicates,
61
+ count(*) FILTER (WHERE outcome = 'other') AS other,
62
+ -- The share only counts fixes whose author is known; unlinked closes say nothing about who fixed them.
63
+ count(*) FILTER (WHERE outcome IN ('outsider', 'insider', 'ai', 'bot')) AS known_fixes
64
+ FROM judged GROUP BY ALL)
65
+ SELECT k.repo_id, k.label, coalesce(c.closed_n, 0) AS closed_n,
66
+ coalesce(c.outsider_fixes, 0) AS outsider_fixes, coalesce(c.insider_fixes, 0) AS insider_fixes,
67
+ coalesce(c.ai_fixes, 0) AS ai_fixes, coalesce(c.bot_fixes, 0) AS bot_fixes,
68
+ coalesce(c.not_planned, 0) AS not_planned, coalesce(c.duplicates, 0) AS duplicates,
69
+ coalesce(c.other, 0) AS other, coalesce(c.known_fixes, 0) AS known_fixes,
70
+ c.outsider_fixes::DOUBLE / nullif(c.known_fixes, 0) AS outsider_share,
71
+ wilson_lo(c.outsider_fixes, c.known_fixes) AS outsider_low,
72
+ wilson_hi(c.outsider_fixes, c.known_fixes) AS outsider_high,
73
+ coalesce(c.known_fixes, 0) >= $min_closed AS enough,
74
+ s.median_fix_lines, s.median_fix_files, coalesce(s.sized, 0) AS sized, coalesce(st.open_stale, 0) AS open_stale
75
+ FROM (SELECT repo_id, label FROM counts UNION SELECT repo_id, label FROM stale) k
76
+ LEFT JOIN counts c USING (repo_id, label) LEFT JOIN sizes s USING (repo_id, label)
77
+ LEFT JOIN stale st USING (repo_id, label)""",
78
+ {"hot": sorted(HOT), "stale_before": now - timedelta(days=rules.stale_days), "min_closed": rules.min_closed,
79
+ "since": now - timedelta(days=rules.window_days)})