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,149 @@
1
+ """How public repos treat outside contributors, from the crawl: how fast a first answer comes, and whether newcomers come
2
+ back. Per-repo facts become percentiles by language and size; the newcomer table backs the "answer first-timers" lever."""
3
+ from datetime import datetime, timedelta
4
+
5
+ import duckdb
6
+
7
+ from mergescout.bench.ci import _pct, quantiles, tier
8
+
9
+ MIN_PRS = 10
10
+ MIN_NEWCOMERS = 10
11
+ MIN_GROUP = 50
12
+ # A repo's first 60 days of crawled history can't tell a newcomer from someone who came before it.
13
+ HISTORY_DAYS = 60
14
+ RETURN_DAYS = 90
15
+ METRICS = ("first_answer_hours", "answered_1d_share", "newcomer_answered_share", "newcomer_return_rate", "bus_factor", "top_share")
16
+ DEPENDENCY_DAYS, MIN_MERGED = 180, 20
17
+ BUCKETS = ("within 1 day", "1-3 days", "3-14 days", "over 14 days", "no answer")
18
+ DEFINITION = ("Outside contributors are people who are not owners, members, collaborators, bots or AI agents. A first "
19
+ "answer is the first review by someone other than the author, a merge or a close. Per repo: median hours "
20
+ "to the first answer and the share answered within a day, for outside PRs opened in the last 90 days "
21
+ "(10+ PRs); for newcomers (an outside contributor's first PR in a repo with 60+ days of history before "
22
+ "it), the share answered within 14 days and the share who opened another PR 1 to 90 days later (10+ newcomers). "
23
+ "Accounts opening 20+ PRs in a repo on one day are left out as automated. Who carries the work: PRs by "
24
+ "people (anyone but bots and AI agents) merged in the last 180 days (20+); the top person's share, and the "
25
+ "bus factor, the fewest people behind half of them.")
26
+
27
+ ANSWER = """least(CASE WHEN p.first_human_review_by IS DISTINCT FROM p.author_login THEN p.first_human_review_at END,
28
+ p.merged_at, p.closed_at)"""
29
+ # One account opening 20+ PRs in a repo on one day is automated (bat: 73 in a day); it would swamp the repo's rates.
30
+ BURST = """burst AS (SELECT DISTINCT repo_id, author_login FROM pr_class WHERE author_class = 'outsider'
31
+ GROUP BY repo_id, author_login, CAST(created_at AS DATE) HAVING count(*) >= 20)"""
32
+ NEWCOMERS = f"""
33
+ WITH span AS (SELECT repo_id, min(created_at) AS start FROM pr_class GROUP BY repo_id),
34
+ {BURST},
35
+ outside AS (SELECT p.*, {ANSWER} AS answered_at FROM pr_class p
36
+ WHERE p.author_class = 'outsider' AND NOT EXISTS (
37
+ SELECT 1 FROM burst b WHERE b.repo_id = p.repo_id AND b.author_login = p.author_login)),
38
+ ranked AS (SELECT o.*, row_number() OVER w AS k
39
+ FROM outside o WINDOW w AS (PARTITION BY o.repo_id, o.author_login ORDER BY o.created_at, o.number)),
40
+ firsts AS (SELECT r.* FROM ranked r JOIN span s USING (repo_id)
41
+ WHERE r.k = 1 AND r.created_at >= s.start + INTERVAL {HISTORY_DAYS} DAY)
42
+ -- Coming back is new work at least a day later: PRs split minutes apart are one contribution.
43
+ SELECT f.*, (SELECT min(o.created_at) FROM outside o WHERE o.repo_id = f.repo_id AND o.author_login = f.author_login
44
+ AND o.created_at >= f.created_at + INTERVAL 1 DAY) AS next_at
45
+ FROM firsts f"""
46
+
47
+
48
+ def newcomer_returns(con: duckdb.DuckDBPyConnection, now: datetime) -> dict:
49
+ """Newcomers whose return can be judged (first PR 120+ days ago), by how long their first PR waited for an answer."""
50
+ judged = f"WITH judged AS (SELECT * FROM ({NEWCOMERS}) WHERE created_at <= $cut)"
51
+ cut = {"cut": now - timedelta(days=RETURN_DAYS + 30)}
52
+ back = f"avg((next_at IS NOT NULL AND next_at <= created_at + INTERVAL {RETURN_DAYS} DAY)::INT)"
53
+ by_answer = con.execute(f"""{judged}
54
+ SELECT CASE WHEN answered_at IS NULL THEN 4 WHEN answered_at - created_at <= INTERVAL 1 DAY THEN 0
55
+ WHEN answered_at - created_at <= INTERVAL 3 DAY THEN 1 WHEN answered_at - created_at <= INTERVAL 14 DAY THEN 2
56
+ ELSE 3 END AS b, count(*), {back}, avg((merged_at IS NOT NULL)::INT)
57
+ FROM judged GROUP BY 1 ORDER BY 1""", cut).fetchall()
58
+ by_outcome = con.execute(f"""{judged}
59
+ SELECT CASE WHEN merged_at IS NOT NULL THEN 'merged' WHEN state = 'CLOSED' THEN 'closed unmerged' ELSE 'still open' END,
60
+ count(*), {back}
61
+ FROM judged GROUP BY 1 ORDER BY 2 DESC""", cut).fetchall()
62
+ n, repos = con.execute(f"{judged} SELECT count(*), count(DISTINCT repo_id) FROM judged", cut).fetchall()[0]
63
+ return {"newcomers": n, "repos": repos, "return_days": RETURN_DAYS,
64
+ "by_answer": [{"bucket": BUCKETS[b], "newcomers": c, "returned": round(r, 3), "merged": round(m, 3)}
65
+ for b, c, r, m in by_answer],
66
+ "by_outcome": [{"outcome": o, "newcomers": c, "returned": round(r, 3)} for o, c, r in by_outcome]}
67
+
68
+
69
+ def repo_metrics(con: duckdb.DuckDBPyConnection, now: datetime) -> list[dict]:
70
+ """The facts behind each repo's community score; a fact without enough PRs or newcomers is None."""
71
+ params = {"lo": now - timedelta(days=90), "hi": now - timedelta(days=1), "seen": now - timedelta(days=14),
72
+ "judged": now - timedelta(days=RETURN_DAYS + 30), "dep": now - timedelta(days=DEPENDENCY_DAYS)}
73
+ rows = con.execute(f"""
74
+ WITH {BURST},
75
+ recent AS (
76
+ SELECT p.repo_id, {ANSWER} AS answered_at, p.created_at FROM pr_class p
77
+ WHERE p.author_class = 'outsider' AND p.created_at >= $lo AND p.created_at < $hi AND NOT EXISTS (
78
+ SELECT 1 FROM burst b WHERE b.repo_id = p.repo_id AND b.author_login = p.author_login)),
79
+ answers AS (
80
+ SELECT repo_id, count(*) AS prs,
81
+ median(epoch(answered_at - created_at) / 3600) FILTER (WHERE answered_at IS NOT NULL) AS first_answer_hours,
82
+ avg((answered_at IS NOT NULL AND answered_at <= created_at + INTERVAL 1 DAY)::INT) AS answered_1d_share
83
+ FROM recent GROUP BY repo_id),
84
+ new AS ({NEWCOMERS}),
85
+ newcomers AS (
86
+ SELECT repo_id,
87
+ count(*) FILTER (WHERE created_at <= $seen) AS seen,
88
+ avg((answered_at IS NOT NULL AND answered_at <= created_at + INTERVAL 14 DAY)::INT)
89
+ FILTER (WHERE created_at <= $seen) AS newcomer_answered_share,
90
+ count(*) FILTER (WHERE created_at <= $judged) AS judged,
91
+ avg((next_at IS NOT NULL AND next_at <= created_at + INTERVAL {RETURN_DAYS} DAY)::INT)
92
+ FILTER (WHERE created_at <= $judged) AS newcomer_return_rate
93
+ FROM new GROUP BY repo_id),
94
+ -- Who carries the work, as the app measures it: people's merged PRs, the fewest people behind half of them.
95
+ by_person AS (
96
+ SELECT repo_id, author_login, count(*) AS n FROM pr_class
97
+ WHERE merged_at >= $dep AND author_role IN ('insider', 'outsider') GROUP BY repo_id, author_login),
98
+ running AS (
99
+ SELECT repo_id, n, sum(n) OVER (PARTITION BY repo_id) AS total,
100
+ sum(n) OVER (PARTITION BY repo_id ORDER BY n DESC, author_login ROWS UNBOUNDED PRECEDING) AS upto,
101
+ row_number() OVER (PARTITION BY repo_id ORDER BY n DESC, author_login) AS k
102
+ FROM by_person),
103
+ dependency AS (
104
+ SELECT repo_id, max(total) AS merged, max(n) / max(total) AS top_share,
105
+ min(k) FILTER (WHERE upto * 2 >= total) AS bus_factor
106
+ FROM running GROUP BY repo_id)
107
+ SELECT rm.repo_id, rm.language, rm.stars, a.prs, a.first_answer_hours, a.answered_1d_share,
108
+ n.seen, n.newcomer_answered_share, n.judged, n.newcomer_return_rate, d.merged, d.top_share, d.bus_factor
109
+ FROM repo_meta rm LEFT JOIN answers a USING (repo_id) LEFT JOIN newcomers n USING (repo_id)
110
+ LEFT JOIN dependency d USING (repo_id)
111
+ WHERE coalesce(a.prs, 0) >= {MIN_PRS} OR coalesce(n.seen, 0) >= {MIN_NEWCOMERS}
112
+ OR coalesce(d.merged, 0) >= {MIN_MERGED}""", params).fetchall()
113
+ out = []
114
+ for repo_id, lang, stars, prs, hours, share_1d, seen, answered, judged, returned, merged, top, bus in rows:
115
+ enough_prs, enough_seen, enough_judged = (prs or 0) >= MIN_PRS, (seen or 0) >= MIN_NEWCOMERS, (judged or 0) >= MIN_NEWCOMERS
116
+ enough_merged = (merged or 0) >= MIN_MERGED
117
+ out.append({"repo_id": repo_id, "language": lang, "stars": stars,
118
+ "first_answer_hours": round(hours, 2) if enough_prs and hours is not None else None,
119
+ "answered_1d_share": round(share_1d, 4) if enough_prs else None,
120
+ "newcomer_answered_share": round(answered, 4) if enough_seen else None,
121
+ "newcomer_return_rate": round(returned, 4) if enough_judged else None,
122
+ "bus_factor": int(bus) if enough_merged else None, "top_share": round(top, 4) if enough_merged else None})
123
+ return out
124
+
125
+
126
+ def community_benchmarks(con: duckdb.DuckDBPyConnection, now: datetime, top_languages: int = 25) -> dict:
127
+ """Percentiles of the community facts by language and size (groups need MIN_GROUP repos), plus the newcomer table."""
128
+ repos = repo_metrics(con, now)
129
+ counts = {}
130
+ for r in repos:
131
+ counts[r["language"] or "Other"] = counts.get(r["language"] or "Other", 0) + 1
132
+ langs = {name for name, _ in sorted(counts.items(), key=lambda kv: -kv[1])[:top_languages]}
133
+ groups = {}
134
+ for r in repos:
135
+ lang = r["language"] if r["language"] in langs else "Other"
136
+ for key in (f"{lang}|{tier(r['stars'])}", f"{lang}|all", f"all|{tier(r['stars'])}", "all|all"):
137
+ groups.setdefault(key, []).append(r)
138
+ out = {}
139
+ for key, items in groups.items():
140
+ if len(items) < MIN_GROUP:
141
+ continue
142
+ entry = {"repos": len(items)}
143
+ for m in METRICS:
144
+ values = [float(r[m]) for r in items if r[m] is not None]
145
+ if len(values) >= MIN_GROUP:
146
+ entry[m] = {"repos": len(values), **quantiles(values)}
147
+ out[key] = entry
148
+ return {"version": 1, "built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "repos": len(repos), "definition": DEFINITION, "population": 'public GitHub repos in the daily crawl with enough outside pull requests and issues to measure', "gaps": 'no private or enterprise teams; GitHub only; a newcomer is someone new to that repo, not to open source',
149
+ "groups": out, "newcomers": newcomer_returns(con, now)}
@@ -0,0 +1,103 @@
1
+ """Delivery benchmarks from the crawl: per repo the P75 of pickup, review and cycle to merge (the suite's nearest-rank
2
+ rule, so a repo's own Delivery page compares like with like), per group the spread with a bootstrap range on the median
3
+ and the share of repos inside LinearB's published bands, with the population and the gaps stated beside the numbers."""
4
+ import math
5
+ from datetime import datetime, timedelta
6
+
7
+ import duckdb
8
+
9
+ from mergescout.bench.ci import BOOTSTRAP_RESAMPLES as RESAMPLES
10
+ from mergescout.bench.ci import BOOTSTRAP_SEED as SEED
11
+ from mergescout.bench.ci import _pct, bootstrap_median, tier
12
+
13
+ MIN_PRS = 10 # PRs a repo needs in the window
14
+ MIN_VALUES = 5 # values a repo needs for one metric
15
+ MIN_GROUP = 50
16
+ METRICS = {"pickup_p75_hours": "pickup", "review_p75_hours": "review", "cycle_to_merge_p75_hours": "cycle"}
17
+ # LinearB's 2026 bands on P75 hours: elite below the first, good up to the second, fair up to the third.
18
+ LINEARB = {"pickup_p75_hours": (1, 4, 16), "review_p75_hours": (3, 14, 24)}
19
+ DEFINITION = ("per repo, PRs opened in the last 90 days by people (bots and AI agents left out), repos with 10+ such PRs: "
20
+ "the P75 (nearest rank) of hours from opening to the first review by a maintainer other than the author "
21
+ "(pickup), from that review to the merge (review), and from opening to the merge (cycle to merge); a repo "
22
+ "needs 5+ values for a metric. Groups by language and stars need 50+ repos.")
23
+ POPULATION = ("public GitHub repos in the daily crawl that had 10+ pull requests by people in the last 90 days; GitHub "
24
+ "only, any CI")
25
+ GAPS = ("no private or enterprise teams; no GitLab or Bitbucket; pickup counts from opening, not from leaving draft (the "
26
+ "crawl does not keep draft history), so repos that open drafts look slower than LinearB's own pickup; cycle to "
27
+ "merge has no coding or deploy time, so it is not LinearB's full cycle time")
28
+ BANDS_NOTE = ("LinearB's 2026 benchmark bands on P75 (pickup: elite under 1 h, good up to 4 h, fair up to 16 h; review: "
29
+ "elite under 3 h, good up to 14 h, fair up to 24 h); each share is the part of the group's repos inside that "
30
+ "band or a better one")
31
+
32
+
33
+ def _p75(values: list[float]) -> float:
34
+ s = sorted(values)
35
+ return s[min(len(s) - 1, max(0, math.ceil(0.75 * len(s)) - 1))]
36
+
37
+
38
+ def per_repo(con: duckdb.DuckDBPyConnection, now: datetime, window_days: int = 90) -> list[dict]:
39
+ rows = con.execute("""
40
+ WITH w AS (
41
+ SELECT p.*, coalesce(p.first_human_review_at, p.first_review_at) AS review_at,
42
+ coalesce(p.first_human_review_by, p.first_review_by) AS review_by
43
+ FROM pr_class p WHERE p.created_at >= $since AND p.author_class IN ('insider', 'outsider'))
44
+ SELECT w.repo_id, any_value(rm.language), any_value(rm.stars), count(*),
45
+ list(epoch(w.review_at - w.created_at) / 3600) FILTER (
46
+ WHERE w.review_at IS NOT NULL AND w.review_by IS DISTINCT FROM w.author_login AND rv.kind = 'insider'),
47
+ list(epoch(w.merged_at - w.review_at) / 3600) FILTER (
48
+ WHERE w.merged_at IS NOT NULL AND w.review_at IS NOT NULL AND w.review_at <= w.merged_at
49
+ AND w.review_by IS DISTINCT FROM w.author_login AND rv.kind = 'insider'),
50
+ list(epoch(w.merged_at - w.created_at) / 3600) FILTER (WHERE w.merged_at IS NOT NULL)
51
+ FROM w LEFT JOIN people rv ON rv.repo_id = w.repo_id AND rv.login = w.review_by
52
+ LEFT JOIN repo_meta rm ON rm.repo_id = w.repo_id
53
+ GROUP BY w.repo_id HAVING count(*) >= $min_prs""",
54
+ {"since": now - timedelta(days=window_days), "min_prs": MIN_PRS}).fetchall()
55
+ out = []
56
+ for repo_id, language, stars, prs, pickup, review, cycle in rows:
57
+ rec = {"repo_id": repo_id, "language": language, "stars": stars, "prs": prs}
58
+ for metric, values in zip(METRICS, (pickup, review, cycle)):
59
+ values = [v for v in values or [] if v is not None and v >= 0]
60
+ rec[metric] = round(_p75(values), 4) if len(values) >= MIN_VALUES else None
61
+ out.append(rec)
62
+ return out
63
+
64
+
65
+ def _entry(metric: str, values: list[float]) -> dict:
66
+ low, high = bootstrap_median(values)
67
+ entry = {"repos": len(values), **{f"p{int(q * 100)}": _pct(values, q) for q in (0.1, 0.25, 0.5, 0.75, 0.9)},
68
+ "p50_low": low, "p50_high": high}
69
+ if metric in LINEARB:
70
+ elite, good, fair = LINEARB[metric]
71
+ n = len(values)
72
+ entry["linearb"] = {"elite": round(sum(v < elite for v in values) / n, 4), "good": round(sum(v <= good for v in values) / n, 4),
73
+ "fair": round(sum(v <= fair for v in values) / n, 4)}
74
+ return entry
75
+
76
+
77
+ def delivery_benchmarks(con: duckdb.DuckDBPyConnection, now: datetime, window_days: int = 90, top_languages: int = 25) -> dict:
78
+ """Delivery percentiles of public repos by language and size; groups need 50+ repos with the metric."""
79
+ repos = per_repo(con, now, window_days)
80
+ counts = {}
81
+ for r in repos:
82
+ counts[r["language"] or "Other"] = counts.get(r["language"] or "Other", 0) + 1
83
+ langs = {name for name, _ in sorted(counts.items(), key=lambda kv: -kv[1])[:top_languages]}
84
+ groups = {}
85
+ for r in repos:
86
+ lang = r["language"] if r["language"] in langs else "Other"
87
+ for key in (f"{lang}|{tier(r['stars'])}", f"{lang}|all", f"all|{tier(r['stars'])}", "all|all"):
88
+ groups.setdefault(key, []).append(r)
89
+ out = {}
90
+ for key, items in groups.items():
91
+ if len(items) < MIN_GROUP:
92
+ continue
93
+ entry = {"repos": len(items)}
94
+ for metric in METRICS:
95
+ values = [r[metric] for r in items if r[metric] is not None]
96
+ if len(values) >= MIN_GROUP:
97
+ entry[metric] = _entry(metric, values)
98
+ out[key] = entry
99
+ return {"version": 1, "built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "window_days": window_days, "repos": len(repos),
100
+ "definition": DEFINITION, "population": POPULATION, "gaps": GAPS, "bands_note": BANDS_NOTE,
101
+ "method": {"percentile": "nearest rank", "bootstrap_resamples": RESAMPLES, "bootstrap_seed": SEED,
102
+ "range": "95% of the resampled medians"},
103
+ "groups": out}
@@ -0,0 +1,261 @@
1
+ """Package -> source repo map from deps.dev (free, anonymous): the default version, release history, licence, advisories,
2
+ and how the repo link is known. A signed link is "verified"; a claimed one is checked against the repo's own manifest
3
+ (user decision D1), since anyone can point a package at a popular repo."""
4
+ import configparser
5
+ import http.client
6
+ import json
7
+ import re
8
+ import time
9
+ import tomllib
10
+ import urllib.error
11
+ import urllib.request
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from datetime import datetime, timedelta
14
+ from urllib.parse import quote
15
+
16
+ from mergescout.github.graphql import GraphQLError, RateLimited
17
+ from mergescout.signals.bumps import _norm
18
+
19
+ API = "https://api.deps.dev"
20
+ SYSTEMS = {"npm": "NPM", "pypi": "PYPI"}
21
+ BATCH = 500
22
+ CLAIM_BATCH = 20
23
+ SAFE = re.compile(r"^[\w.-]+$")
24
+ SAFE_DIR = re.compile(r"^[\w.@+-]+(/[\w.@+-]+)*$")
25
+ SKIPPED = object()
26
+ # Pre-release markers in PyPI (1.0.0.dev6, 2.0rc1) and npm (6.0.0-beta.3) versions.
27
+ PRERELEASE = re.compile(r"(\d(a|b|rc|c)\d+|\.?dev\d*|\.?post\d*\.dev|-[0-9A-Za-z])", re.I)
28
+ # Dropped connections and resets come as several types; all of them are worth a retry.
29
+ NETWORK = (OSError, http.client.HTTPException)
30
+
31
+
32
+ def http_get_json(url: str, tries: int = 3) -> dict | None:
33
+ # deps.dev states no rate limit; a short back-off covers its rare 5xx answers. 404 means "not known".
34
+ for attempt in range(tries):
35
+ try:
36
+ with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "mergescout"}), timeout=30) as r:
37
+ return json.loads(r.read())
38
+ except urllib.error.HTTPError as exc:
39
+ if exc.code == 404:
40
+ return None
41
+ if attempt == tries - 1:
42
+ raise
43
+ except NETWORK:
44
+ if attempt == tries - 1:
45
+ raise
46
+ time.sleep(2 ** attempt)
47
+ return None
48
+
49
+
50
+ def http_post_json(url: str, body: dict, tries: int = 3) -> dict:
51
+ data = json.dumps(body).encode()
52
+ for attempt in range(tries):
53
+ try:
54
+ req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json", "User-Agent": "mergescout"})
55
+ with urllib.request.urlopen(req, timeout=60) as r:
56
+ return json.loads(r.read())
57
+ except NETWORK:
58
+ if attempt == tries - 1:
59
+ raise
60
+ time.sleep(2 ** attempt)
61
+ return {}
62
+
63
+
64
+ def lookup_packages(packages: list[tuple[str, str]], get_json=http_get_json, post_json=http_post_json,
65
+ now: datetime | None = None, workers: int = 8, budget_s: float | None = None) -> dict[tuple[str, str], dict]:
66
+ """Per (ecosystem, name): default version, releases, repo and link kind, licences, advisories, deprecation.
67
+ deps.dev slows down after many calls (23 s vs 333 s for the same 300 packages), so a time budget stops early."""
68
+ wanted = list(dict.fromkeys((eco, name) for eco, name in packages if eco in SYSTEMS))
69
+ deadline = time.time() + budget_s if budget_s else None
70
+
71
+ def fetch(pair):
72
+ # Past the budget nothing more is asked; those packages keep their previous answer.
73
+ if deadline and time.time() > deadline:
74
+ return SKIPPED
75
+ try:
76
+ return get_json(f"{API}/v3/systems/{SYSTEMS[pair[0]]}/packages/{quote(pair[1], safe='')}")
77
+ except Exception: # noqa: BLE001
78
+ # One lost answer after retries only skips that package (it keeps its last answer).
79
+ return SKIPPED
80
+ with ThreadPoolExecutor(workers) as pool:
81
+ found = list(pool.map(fetch, wanted))
82
+ year_ago = ((now or datetime.now()) - timedelta(days=365)).strftime("%Y-%m-%d")
83
+ out, keys = {}, []
84
+ for (eco, name), pkg in zip(wanted, found):
85
+ if pkg is SKIPPED:
86
+ continue
87
+ info = {"ecosystem": eco, "package": name, "version": None, "repo": None, "link": None, "licenses": [],
88
+ "advisories": [], "deprecated": False, "deprecated_reason": None, "status": None, "releases_12m": 0,
89
+ "last_release": None, "first_release": None}
90
+ vs = [v for v in (pkg or {}).get("versions") or [] if v.get("publishedAt")]
91
+ if vs:
92
+ dates = sorted(v["publishedAt"][:10] for v in vs)
93
+ default = next((v for v in vs if v.get("isDefault")), max(vs, key=lambda v: v["publishedAt"]))
94
+ stable = [v for v in vs if not PRERELEASE.search(v["versionKey"]["version"])]
95
+ # A pre-release default (httpx 1.0.0.dev6) often lacks the repo link; the newest stable release speaks for the package.
96
+ if PRERELEASE.search(default["versionKey"]["version"]) and stable:
97
+ default = max(stable, key=lambda v: v["publishedAt"])
98
+ # Nightly and dev builds are not releases; a package with only pre-releases keeps them.
99
+ counted = sorted(v["publishedAt"][:10] for v in stable) or dates
100
+ info.update(version=default["versionKey"]["version"], first_release=dates[0], last_release=counted[-1],
101
+ releases_12m=sum(d >= year_ago for d in counted), release_rule=RELEASE_RULE)
102
+ keys.append((eco, name, info["version"]))
103
+ out[(eco, name)] = info
104
+ by_key = {(SYSTEMS[e], n, v): (e, n) for e, n, v in keys}
105
+ for i in range(0, len(keys), BATCH):
106
+ body = {"requests": [{"versionKey": {"system": SYSTEMS[e], "name": n, "version": v}} for e, n, v in keys[i:i + BATCH]]}
107
+ token = None
108
+ # deps.dev answers 100 versions per page and says where to go on; answers are matched by key, not position.
109
+ while True:
110
+ page = post_json(f"{API}/v3alpha/versionbatch", {**body, "pageToken": token} if token else body)
111
+ for resp in page.get("responses") or []:
112
+ k = ((resp or {}).get("request") or {}).get("versionKey") or {}
113
+ found = by_key.get((k.get("system"), k.get("name"), k.get("version")))
114
+ if found and (resp or {}).get("version"):
115
+ _fill(out[found], resp["version"])
116
+ token = page.get("nextPageToken")
117
+ if not token:
118
+ break
119
+ return out
120
+
121
+
122
+ def _fill(info: dict, v: dict) -> None:
123
+ repos = [p for p in v.get("relatedProjects") or [] if p.get("relationType") == "SOURCE_REPO"]
124
+ # A signed build or publish attestation proves the link; plain metadata is only what the publisher typed.
125
+ repos.sort(key=lambda p: p.get("relationProvenance") == "UNVERIFIED_METADATA")
126
+ if repos:
127
+ project = repos[0]["projectKey"]["id"]
128
+ info["repo"] = project.removeprefix("github.com/")
129
+ info["link"] = "claimed" if repos[0].get("relationProvenance") == "UNVERIFIED_METADATA" else "verified"
130
+ info["licenses"] = v.get("licenses") or []
131
+ info["advisories"] = [a.get("id") for a in v.get("advisoryKeys") or [] if a.get("id")]
132
+ info["deprecated"] = bool(v.get("isDeprecated"))
133
+ # The registry's own words, cut short: shown as a quote, never acted on.
134
+ reason = " ".join((v.get("deprecatedReason") or "").split())[:160]
135
+ info["deprecated_reason"] = reason or None if info["deprecated"] else None
136
+ info["status"] = (v.get("projectStatus") or {}).get("status")
137
+
138
+
139
+ def manifest_name(path: str, text: str | None) -> str | None:
140
+ """The package name a manifest declares: package.json, pyproject.toml ([project] or Poetry) or setup.cfg."""
141
+ if not text:
142
+ return None
143
+ try:
144
+ if path.endswith("package.json"):
145
+ return json.loads(text).get("name")
146
+ if path.endswith("pyproject.toml"):
147
+ doc = tomllib.loads(text)
148
+ return (doc.get("project") or {}).get("name") or ((doc.get("tool") or {}).get("poetry") or {}).get("name")
149
+ if path.endswith("setup.cfg"):
150
+ cfg = configparser.ConfigParser()
151
+ cfg.read_string(text)
152
+ return cfg.get("metadata", "name", fallback=None)
153
+ if path.endswith("setup.py"):
154
+ return _setup_py_name(text)
155
+ except (ValueError, tomllib.TOMLDecodeError, configparser.Error, AttributeError):
156
+ return None
157
+ return None
158
+
159
+
160
+ def _setup_py_name(text: str) -> str | None:
161
+ # setup(name="x"), or setup(name=NAME) with NAME = "x" in the same file; anything computed is not guessed.
162
+ call = text[text.find("setup("):] if "setup(" in text else ""
163
+ if m := re.search(r"""\bname\s*=\s*['"]([^'"]+)['"]""", call):
164
+ return m.group(1)
165
+ if m := re.search(r"\bname\s*=\s*([A-Za-z_]\w*)\s*[,)\n]", call):
166
+ const = re.search(rf"""^\s*{m.group(1)}\s*=\s*['"]([^'"]+)['"]""", text, re.M)
167
+ return const.group(1) if const else None
168
+ return None
169
+
170
+
171
+ def _npm_directory(name: str, get_json) -> str | None:
172
+ try:
173
+ d = get_json(f"https://registry.npmjs.org/{quote(name, safe='@')}/latest")
174
+ repo = d.get("repository") if isinstance(d, dict) else None
175
+ raw = repo.get("directory") if isinstance(repo, dict) else None
176
+ except Exception: # noqa: BLE001
177
+ return None
178
+ # Publishers write anything here (numbers, lists): only a plain string is used.
179
+ path = raw.strip().removeprefix("./").strip("/") if isinstance(raw, str) else ""
180
+ # The folder goes into the GitHub query, so only plain path names are used.
181
+ return path if SAFE_DIR.match(path) and ".." not in path.split("/") else None
182
+
183
+
184
+ def _candidates(eco: str, name: str, directory: str | None = None) -> list[str]:
185
+ # Where projects keep the manifest that publishes the package, from the layouts of 33 real unconfirmed links.
186
+ if eco == "pypi":
187
+ folders = ["", f"{name}/", f"{name.replace('-', '_')}/", f"libs/{name}/", f"packages/{name}/", "python/", f"{name}-python/"]
188
+ return list(dict.fromkeys([f"{d}{f}" for d in folders for f in ("pyproject.toml", "setup.py")] + ["setup.cfg"]))
189
+ short = name.split("/")[-1]
190
+ scoped = name.lstrip("@").replace("/", "-")
191
+ first = [f"{directory}/package.json"] if directory else []
192
+ return list(dict.fromkeys(first + ["package.json", f"packages/{short}/package.json", f"packages/{scoped}/package.json",
193
+ f"{short}/package.json", "npm/package.json"]))
194
+
195
+
196
+ def check_claims(infos: dict, gql, get_json=None) -> int:
197
+ """Claimed GitHub links become "claimed_checked" when the repo's manifest names the package, else
198
+ "claimed_unconfirmed"; about 1 GraphQL point per 25 repos. With get_json, npm's repository.directory is looked in
199
+ first (monorepos). Returns how many were checked."""
200
+ todo = [i for i in infos.values() if i.get("link") == "claimed" and i.get("repo") and "/" in i["repo"]
201
+ and all(SAFE.match(part) for part in i["repo"].split("/", 1))]
202
+ for i in [x for x in infos.values() if x.get("link") == "claimed" and x not in todo]:
203
+ i["link"] = "claimed_unconfirmed"
204
+ npm = [i for i in todo if i["ecosystem"] == "npm"] if get_json is not None else []
205
+ with ThreadPoolExecutor(16) as pool:
206
+ for info, path in zip(npm, pool.map(lambda i: _npm_directory(i["package"], get_json), npm)):
207
+ info["directory"] = path
208
+ checked = 0
209
+ for start in range(0, len(todo), CLAIM_BATCH):
210
+ chunk = todo[start:start + CLAIM_BATCH]
211
+ lines, files = [], {}
212
+ for r, info in enumerate(chunk):
213
+ owner, name = info["repo"].split("/", 1)
214
+ lines.append(f' r{r}: repository(owner: "{owner}", name: "{name}") {{')
215
+ for f, path in enumerate(_candidates(info["ecosystem"], info["package"], info.get("directory"))):
216
+ files[(r, f)] = path
217
+ lines.append(f' f{f}: object(expression: "HEAD:{path}") {{ ... on Blob {{ text }} }}')
218
+ lines.append(" }")
219
+ query = "query {\n rateLimit { cost remaining resetAt }\n" + "\n".join(lines) + "\n}"
220
+ try:
221
+ data = gql.query(query, {}, partial=True).data
222
+ except (GraphQLError, RateLimited):
223
+ # The rest stay "claimed" (not yet checked) and are tried on the next run.
224
+ return checked
225
+ checked += len(chunk)
226
+ for r, info in enumerate(chunk):
227
+ repo = data.get(f"r{r}") or {}
228
+ names = {manifest_name(path, ((repo.get(f"f{f}") or {}).get("text"))) for (rr, f), path in files.items() if rr == r}
229
+ match = {_norm(info["ecosystem"], n) for n in names if n} & {_norm(info["ecosystem"], info["package"])}
230
+ info["link"] = "claimed_checked" if match else "claimed_unconfirmed"
231
+ return checked
232
+
233
+
234
+ MAX_AGE_DAYS = 7
235
+ # How releases are counted; answers from an older rule are looked up again (2: pre-releases no longer count).
236
+ RELEASE_RULE = 2
237
+
238
+
239
+ def refresh_map(packages: list[tuple[str, str]], previous: dict, now: datetime, gql=None, budget_s: float = 600,
240
+ lookup=lookup_packages, check=check_claims, get_json=http_get_json) -> dict:
241
+ """Yesterday's map with new and week-old packages looked up again (within the time budget) and their claimed
242
+ links checked when a GitHub token is at hand; a package skipped by the budget keeps its last answer."""
243
+ stamp = now.strftime("%Y-%m-%dT%H:%M:%SZ")
244
+ stale_before = (now - timedelta(days=MAX_AGE_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ")
245
+ keep, todo = {}, []
246
+ for eco, name in dict.fromkeys(packages):
247
+ old = previous.get(f"{eco}:{name}")
248
+ # Answers built on a pre-release (before stable versions were preferred) are asked again.
249
+ if (old and (old.get("fetched_at") or "") >= stale_before and not PRERELEASE.search(old.get("version") or "")
250
+ and old.get("release_rule", 1) >= RELEASE_RULE):
251
+ keep[f"{eco}:{name}"] = old
252
+ else:
253
+ todo.append((eco, name))
254
+ if old:
255
+ keep[f"{eco}:{name}"] = old
256
+ fresh = lookup(todo, now=now, budget_s=budget_s)
257
+ if gql is not None:
258
+ check(fresh, gql, get_json=get_json)
259
+ for (eco, name), info in fresh.items():
260
+ keep[f"{eco}:{name}"] = {**info, "fetched_at": stamp}
261
+ return keep
@@ -0,0 +1,117 @@
1
+ """How a package's source repo treats outside work, quarter by quarter, from the crawl's PR history (12 months): outside
2
+ PRs merged, first maintainer answer, how much merging rests on one person; compared with repos of similar size."""
3
+ from datetime import datetime
4
+
5
+ import duckdb
6
+
7
+ from mergescout.bench.ci import tier
8
+
9
+ MIN_DECIDED = 5
10
+ MIN_RESPONSES = 5
11
+ MIN_MERGES = 5
12
+ # Changes smaller than these are "steady": quarters of a few dozen PRs move around on their own.
13
+ RATE_STEP = 0.15
14
+ SPEED_RATIO = 1.5
15
+ # Automation that merges (Actions, merge queues, any login the crawl saw acting as a bot) is not one person merging.
16
+ BOT_MERGER = ("(lower({col}) LIKE '%[bot]' OR lower({col}) IN ('github-actions', 'github-merge-queue', 'mergify', 'kodiakhq', "
17
+ "'bors', 'dependabot', 'renovate') OR lower({col}) IN (SELECT lower(login) FROM people WHERE kind = 'bot'))")
18
+
19
+
20
+ def _quarters(now: datetime, n: int = 4) -> list[tuple[int, int]]:
21
+ # The last n complete quarters, oldest first (the running quarter is partial).
22
+ year, q = now.year, (now.month - 1) // 3 + 1
23
+ out = []
24
+ for _ in range(n):
25
+ q -= 1
26
+ if q == 0:
27
+ year, q = year - 1, 4
28
+ out.append((year, q))
29
+ return out[::-1]
30
+
31
+
32
+ def repo_trends(con: duckdb.DuckDBPyConnection, repos: list[str], now: datetime) -> dict[str, dict]:
33
+ """Per repo ("owner/name"): quarterly lines, their direction and the latest quarter's place among similar repos."""
34
+ quarters = _quarters(now)
35
+ lo = datetime(quarters[0][0], 3 * quarters[0][1] - 2, 1, tzinfo=now.tzinfo)
36
+ end_y, end_q = (now.year, (now.month - 1) // 3 + 1)
37
+ hi = datetime(end_y, 3 * end_q - 2, 1, tzinfo=now.tzinfo)
38
+ wanted = sorted({r.lower() for r in repos})
39
+ if not wanted:
40
+ return {}
41
+ rows = con.execute(f"""
42
+ WITH wanted AS (SELECT repo_id, lower(name) AS repo, stars FROM repo_meta WHERE lower(name) IN (SELECT unnest($repos))),
43
+ w AS (SELECT p.*, wanted.repo, year(timezone('UTC', p.created_at)) AS y, quarter(timezone('UTC', p.created_at)) AS q
44
+ FROM pr_class p JOIN wanted USING (repo_id) WHERE p.created_at >= $lo AND p.created_at < $hi)
45
+ SELECT w.repo, w.y, w.q,
46
+ count(*) FILTER (WHERE author_class = 'outsider') AS opened,
47
+ count(*) FILTER (WHERE author_class = 'outsider' AND state IN ('MERGED', 'CLOSED')) AS decided,
48
+ count(*) FILTER (WHERE author_class = 'outsider' AND merged_at IS NOT NULL) AS merged,
49
+ median(epoch(first_human_review_at - created_at) / 3600) FILTER (
50
+ WHERE author_class = 'outsider' AND first_human_review_at >= created_at AND pe.kind = 'insider') AS response,
51
+ count(*) FILTER (WHERE author_class = 'outsider' AND first_human_review_at >= created_at AND pe.kind = 'insider')
52
+ AS responses,
53
+ count(*) FILTER (WHERE merged_at IS NOT NULL AND merged_by IS NOT NULL
54
+ AND NOT {BOT_MERGER.format(col='merged_by')}) AS merges
55
+ FROM w LEFT JOIN people pe ON pe.repo_id = w.repo_id AND pe.login = w.first_human_review_by
56
+ GROUP BY ALL""", {"repos": wanted, "lo": lo, "hi": hi}).fetchall()
57
+ tops = {}
58
+ for repo, y, q, n in con.execute(f"""
59
+ SELECT lower(rm.name), year(timezone('UTC', p.created_at)), quarter(timezone('UTC', p.created_at)), count(*)
60
+ FROM pr_class p JOIN repo_meta rm USING (repo_id)
61
+ WHERE lower(rm.name) IN (SELECT unnest($repos)) AND p.merged_at IS NOT NULL AND p.merged_by IS NOT NULL
62
+ AND NOT {BOT_MERGER.format(col='p.merged_by')} AND p.created_at >= $lo AND p.created_at < $hi
63
+ GROUP BY 1, 2, 3, p.merged_by""", {"repos": wanted, "lo": lo, "hi": hi}).fetchall():
64
+ tops[(repo, y, q)] = max(tops.get((repo, y, q), 0), n)
65
+ stars = dict(con.execute("SELECT lower(name), stars FROM repo_meta WHERE lower(name) IN (SELECT unnest($repos))",
66
+ {"repos": wanted}).fetchall())
67
+ cells = {(r[0], r[1], r[2]): r for r in rows}
68
+ out = {}
69
+ for repo in wanted:
70
+ if repo not in stars:
71
+ continue
72
+ lines = []
73
+ for y, q in quarters:
74
+ r = cells.get((repo, y, q))
75
+ opened, decided, merged, response, responses, merges = r[3:] if r else (0, 0, 0, None, 0, 0)
76
+ lines.append({"quarter": f"{y}Q{q}", "outside_opened": opened, "outside_decided": decided,
77
+ "outside_merge_rate": round(merged / decided, 2) if decided >= MIN_DECIDED else None,
78
+ "first_response_hours": round(response, 1) if responses >= MIN_RESPONSES else None,
79
+ "merges": merges,
80
+ "top_merger_share": round(tops.get((repo, y, q), 0) / merges, 2) if merges >= MIN_MERGES else None})
81
+ out[repo] = {"stars": stars[repo], "quarters": lines, "direction": _direction(lines)}
82
+ _peers(out)
83
+ return out
84
+
85
+
86
+ def _direction(lines: list[dict]) -> dict:
87
+ """Earlier half against later half of the year, in plain words; None when either half has no value."""
88
+ def halves(key):
89
+ a = [x[key] for x in lines[:2] if x[key] is not None]
90
+ b = [x[key] for x in lines[2:] if x[key] is not None]
91
+ return (sum(a) / len(a), sum(b) / len(b)) if a and b else (None, None)
92
+ out = {}
93
+ a, b = halves("outside_merge_rate")
94
+ out["outside_merge_rate"] = None if a is None else "falling" if b - a <= -RATE_STEP else "rising" if b - a >= RATE_STEP else "steady"
95
+ a, b = halves("first_response_hours")
96
+ out["first_response_hours"] = None if a is None else ("slower" if b >= a * SPEED_RATIO and b - a > 24 else
97
+ "faster" if a >= b * SPEED_RATIO and a - b > 24 else "steady")
98
+ return out
99
+
100
+
101
+ def _peers(out: dict) -> None:
102
+ # The latest quarter against mapped repos of the same stars tier (all of them when a tier has under 5).
103
+ latest = {repo: t["quarters"][-1] for repo, t in out.items()}
104
+ for repo, t in out.items():
105
+ same = [r for r in out if r != repo and tier(out[r]["stars"]) == tier(t["stars"])]
106
+ pool = same if len(same) >= 5 else [r for r in out if r != repo]
107
+ t["peers"] = {}
108
+ for key, better, words in (("outside_merge_rate", 1, ("merges outside PRs more than most similar repos",
109
+ "merges outside PRs less than most similar repos")),
110
+ ("first_response_hours", -1, ("faster than most similar repos", "slower than most similar repos"))):
111
+ mine = latest[repo][key]
112
+ others = [latest[r][key] for r in pool if latest[r][key] is not None]
113
+ if mine is None or not others:
114
+ continue
115
+ ahead = sum(1 for v in others if (v - mine) * better > 0) / len(others)
116
+ place = words[1] if ahead >= 0.75 else words[0] if ahead <= 0.25 else "about typical for similar repos"
117
+ t["peers"][key] = {"place": place, "peers": len(others)}