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,507 @@
1
+ """Where contributors come from and drop out: first answers, the newcomer funnel, newcomers still waiting, star growth and
2
+ traffic, with the same definitions as the crawl benchmarks so the peer comparison is fair."""
3
+ from collections import Counter
4
+ from datetime import datetime, timedelta
5
+
6
+ from mergescout.bench.ci import tier
7
+ from mergescout.bench.stars import star_stats
8
+ from stackvitals.ci.bench import RANKS, _about, _label, _rank
9
+ from stackvitals.core import changes, stats
10
+ from stackvitals.core.actors import INSIDER_ASSOC, People, kind_of
11
+ from stackvitals.core.time import iso as _iso
12
+ from stackvitals.core.time import parse as _dt
13
+
14
+ INSIDERS = INSIDER_ASSOC
15
+ DAY = timedelta(days=1)
16
+ HISTORY_DAYS = 60 # a repo's first 60 days of history can't tell a newcomer from someone who came before
17
+ RETURN_DAYS = 90
18
+ MIN_PRS = 10
19
+ MIN_NEWCOMERS = 10
20
+ BURST = 20
21
+ DEPENDENCY_DAYS, MIN_MERGED, MIN_TENURE = 180, 20, 10 # who carries the work; tenure groups need 10
22
+ # Regular responders, checked against labelled crawl comments (bench/accuracy/community).
23
+ RESPONDER_ISSUES, RESPONDER_REVIEWS, RESPONDER_DAYS = 10, 2, 360
24
+ # Only these associations can be a first contribution; CONTRIBUTOR means earlier commits, even before our history.
25
+ FIRST_TIMERS = {"NONE", "FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", None}
26
+ # The facts behind the score: lower is better for waits, higher for the shares.
27
+ SCORE_FACTS = [("first_answer_hours", "First answer to outside PRs", "hours", -1),
28
+ ("newcomer_answered_share", "Newcomers answered within 2 weeks", "share", 1),
29
+ ("newcomer_return_rate", "Newcomers who come back within 90 days", "share", 1)]
30
+
31
+
32
+ def _hours(a: str, b: str) -> float:
33
+ # Signed: callers compare orderings themselves.
34
+ return (_dt(b) - _dt(a)).total_seconds() / 3600
35
+
36
+
37
+ def _median(values: list[float]) -> float | None:
38
+ m = stats.median(values)
39
+ return None if m is None else round(m, 2)
40
+
41
+
42
+ def _human(login: str | None, typ: str | None) -> bool:
43
+ return kind_of(login, typ) == "person"
44
+
45
+
46
+ def insiders(prs: list[dict], issues: list[dict]) -> set[str]:
47
+ """Maintainers by the shared rule: a listed role anywhere, or anyone who merged a PR (private org members show as
48
+ CONTRIBUTOR; pipx's main merger did); bots and agents never."""
49
+ return People(prs, issues).insiders()
50
+
51
+
52
+ def regular_responders(prs: list[dict], issues: list[dict], ins: set | frozenset, now: datetime) -> set[str]:
53
+ """People without a listed role who answer others: comments on 10+ other people's issues, or the first review on 2+
54
+ other people's PRs, in the last 360 days (triagers and private org members show as CONTRIBUTOR)."""
55
+ since = _iso(now - timedelta(days=RESPONDER_DAYS))
56
+ on, first = Counter(), Counter()
57
+ for r in issues:
58
+ on.update({c[1] for c in r.get("cm") or [] if len(c) > 3 and c[0] and c[0] >= since and c[1] != r.get("au")
59
+ and _human(c[1], c[3])})
60
+ for r in prs:
61
+ got = min(((e[1], e[2]) for e in r.get("ev") or [] if e[0] == "review" and len(e) > 5 and e[1] and e[2]
62
+ and e[2] != r.get("au") and e[3] != "PENDING" and _human(e[2], e[5])), default=None)
63
+ if got and got[0] >= since:
64
+ first[got[1]] += 1
65
+ found = {p for p, n in on.items() if n >= RESPONDER_ISSUES} | {p for p, n in first.items() if n >= RESPONDER_REVIEWS}
66
+ return (found | triagers(issues, now)) - set(ins)
67
+
68
+
69
+ def triagers(issues: list[dict], now: datetime) -> set[str]:
70
+ """People who labelled, assigned, marked a duplicate or closed someone else's issue in the last 360 days: GitHub lets
71
+ only people with triage rights do that, so they act for the project whatever role their comments show. On labelled
72
+ public issues this found 8 project answers among 11 comments the regular-responder rule missed (bench/accuracy/community)."""
73
+ since = _iso(now - timedelta(days=RESPONDER_DAYS))
74
+ out = set()
75
+ for r in issues:
76
+ # Each act counts by its own date: [login, when].
77
+ out |= {t[0] for t in r.get("tr") or [] if isinstance(t, (list, tuple)) and len(t) == 2 and (t[1] or "") >= since}
78
+ closer = _closer(r)
79
+ if closer and closer != r.get("au") and not closer.endswith("[bot]") and (r.get("cl") or "") >= since:
80
+ out.add(closer)
81
+ return out
82
+
83
+
84
+ def _for(rec: dict, ins: set, resp: set | None) -> set:
85
+ # Regular responders answer issues and discussions for the project; PRs still need a maintainer, as the crawl
86
+ # benchmarks count them.
87
+ return resp if resp is not None and rec.get("t") in ("issue", "discussion") else ins
88
+
89
+
90
+ def bursts(prs: list[dict], issues: list[dict], ins: set | frozenset = frozenset()) -> set[str]:
91
+ # An outside account opening BURST items on one day is automated (bat: 73 PRs on one day); maintainers do this too.
92
+ days = Counter((r["au"], r["cr"][:10]) for r in prs + issues if r.get("au") and r.get("cr") and r["au"] not in ins
93
+ and r.get("as") not in INSIDERS)
94
+ return {au for (au, _), n in days.items() if n >= BURST}
95
+
96
+
97
+ def outside(rec: dict, ins: set | frozenset = frozenset(), skip: set | frozenset = frozenset()) -> bool:
98
+ au = rec.get("au")
99
+ return rec.get("as") not in INSIDERS and _human(au, rec.get("at")) and au not in ins and au not in skip
100
+
101
+
102
+ def _closer(rec: dict) -> str | None:
103
+ if rec.get("clb"):
104
+ return rec["clb"]
105
+ closes = sorted((e for e in rec.get("ev") or [] if e[0] == "closed" and len(e) > 2), key=lambda e: e[1])
106
+ return closes[-1][2] if closes else None
107
+
108
+
109
+ def answered_at(rec: dict, ins: set | None = None, comments: bool = True) -> str | None:
110
+ """The first answer: with the maintainers given, a maintainer's review or reply, a merge, or a close by someone other
111
+ than the author. Without them it is the crawl benchmarks' answer: any other person's review, a merge or a close."""
112
+ au, strict = rec.get("au"), ins is not None
113
+ times = [rec.get("mg")]
114
+ # A close counts unless we know the author closed it (records saved before closers were kept count it); a
115
+ # discussion's closer isn't read, so its close never counts.
116
+ if rec.get("cl") and rec.get("t") != "discussion" and not (strict and _closer(rec) == au):
117
+ times.append(rec["cl"])
118
+ for e in rec.get("ev") or []:
119
+ if e[0] == "review" and len(e) > 5 and e[5] == "User" and e[2] and e[2] != au and not e[2].endswith("[bot]") \
120
+ and e[3] != "PENDING" and (not strict or e[2] in ins or e[4] in INSIDERS):
121
+ times.append(e[1])
122
+ if comments:
123
+ for c in rec.get("cm") or []:
124
+ if c[0] and c[1] != au and _human(c[1], c[3]) and (not strict or c[1] in ins or c[2] in INSIDERS):
125
+ times.append(c[0])
126
+ times = [x for x in times if x and x >= (rec.get("cr") or "")]
127
+ return min(times) if times else None
128
+
129
+
130
+ def covered_since(prs: list[dict], issues: list[dict]) -> str | None:
131
+ """How far back the history is complete: items are read when updated in the last 360 days, so an old item updated
132
+ lately doesn't mean everything since it was seen."""
133
+ recs = [r for r in prs + issues if r.get("cr")]
134
+ if not recs:
135
+ return None
136
+ oldest = min(r["cr"] for r in recs)
137
+ first_fetch = min((r["f"] for r in recs if r.get("f")), default=None)
138
+ return max(oldest, _iso(_dt(first_fetch) - timedelta(days=360))) if first_fetch else oldest
139
+
140
+
141
+ def _contributions(prs: list[dict], issues: list[dict], ins=frozenset(), skip=frozenset()) -> dict[str, list[dict]]:
142
+ by = {}
143
+ for r in sorted(prs + issues, key=lambda r: (r.get("cr") or "", r.get("n") or 0)):
144
+ if r.get("cr") and outside(r, ins, skip):
145
+ by.setdefault(r["au"], []).append(r)
146
+ return by
147
+
148
+
149
+ def _came_back(items: list[dict]) -> bool:
150
+ # New work at least a day later: an issue and the PR fixing it minutes apart are one contribution.
151
+ first = items[0]["cr"]
152
+ return any(24 <= _hours(first, r["cr"]) <= RETURN_DAYS * 24 for r in items[1:])
153
+
154
+
155
+ def funnel(prs: list[dict], issues: list[dict], now: datetime, ins: set | None = None) -> dict:
156
+ """First-time contributors of the last year, how many came back within 90 days, regulars and maintainers."""
157
+ ins = insiders(prs, issues) if ins is None else ins
158
+ skip = bursts(prs, issues, ins)
159
+ start = covered_since(prs, issues) or _iso(now)
160
+ new_from = max(_iso(_dt(start) + timedelta(days=HISTORY_DAYS)), _iso(now - timedelta(days=365)))
161
+ judged_until = _iso(now - timedelta(days=RETURN_DAYS))
162
+ newcomers = judged = returned = 0
163
+ quarters = {}
164
+ for login, items in _contributions(prs, issues, ins, skip).items():
165
+ for r in items:
166
+ age = (now - _dt(r["cr"])).days
167
+ if age < 364:
168
+ quarters.setdefault(login, set()).add(age // 91)
169
+ if items[0]["cr"] < new_from:
170
+ continue
171
+ newcomers += 1
172
+ if items[0]["cr"] <= judged_until:
173
+ judged += 1
174
+ returned += _came_back(items)
175
+ year = _iso(now - timedelta(days=365))
176
+ active = set()
177
+ for r in prs + issues:
178
+ if r.get("au") in ins and (r.get("cr") or "") >= year:
179
+ active.add(r["au"])
180
+ active |= {e[2] for e in r.get("ev") or [] if e[0] in ("review", "merged") and len(e) > 2 and e[2] in ins and e[1] >= year}
181
+ active |= {c[1] for c in r.get("cm") or [] if c[1] in ins and c[0] >= year}
182
+ return {"newcomers": newcomers, "judged": judged, "returned": returned,
183
+ "return_rate": round(returned / judged, 4) if judged else None,
184
+ "regulars": sum(1 for q in quarters.values() if len(q) >= 3), "maintainers": len(active)}
185
+
186
+
187
+ def _answers(items: list[dict], ins: set, earlier: int, now: datetime | None = None, resp: set | None = None) -> dict:
188
+ answers = [answered_at(r, _for(r, ins, resp)) for r in items]
189
+ waits = [_hours(r["cr"], a) for r, a in zip(items, answers) if a]
190
+ # Items still waiting count as "not answered after this long" (Kaplan-Meier), so slow repos can't look fast.
191
+ km = None
192
+ if now:
193
+ # An item closed without an answer stops the clock at its close; only open ones are still waiting.
194
+ km = stats.km_median([_hours(r["cr"], a or r.get("cl") or r.get("mg") or _iso(now)) for r, a in zip(items, answers)],
195
+ [bool(a) for a in answers])
196
+ km["closed_unanswered"] = sum(1 for r, a in zip(items, answers) if not a and (r.get("cl") or r.get("mg")))
197
+ km["waiting"] = sum(1 for r, a in zip(items, answers) if not a and not (r.get("cl") or r.get("mg")))
198
+ return {"items": len(items), "answered": round(len(waits) / len(items), 4) if items else None,
199
+ "median_hours": _median(waits), "km": km,
200
+ "within_1d": round(sum(w <= 24 for w in waits) / len(items), 4) if items else None, "earlier": earlier}
201
+
202
+
203
+ def first_answer(prs: list[dict], issues: list[dict], now: datetime, days: int = 90, ins: set | None = None,
204
+ resp: set | None = None, discussions: list[dict] | None = None) -> dict:
205
+ """Outside contributors' PRs and issues of the last `days` (at least a day old): how often and how fast the project
206
+ answered (a maintainer, or for issues also a regular responder); automated accounts are left out and counted, and
207
+ the 9 months before say whether outside work stopped."""
208
+ ins = insiders(prs, issues) if ins is None else ins
209
+ resp = set(ins) | regular_responders(prs, issues, ins, now) if resp is None else resp
210
+ skip = bursts(prs, issues, ins)
211
+ lo, hi, year = _iso(now - timedelta(days=days)), _iso(now - DAY), _iso(now - timedelta(days=365))
212
+
213
+ def pick(rs):
214
+ return [r for r in rs if outside(r, ins, skip) and lo <= (r.get("cr") or "") < hi and not r.get("draft")]
215
+
216
+ def before(rs):
217
+ return sum(1 for r in rs if outside(r, ins, skip) and year <= (r.get("cr") or "") < lo)
218
+ left = [r for r in prs + issues if r.get("au") in skip and lo <= (r.get("cr") or "") < hi]
219
+ return {"days": days, "prs": _answers(pick(prs), ins, before(prs), now),
220
+ "issues": {**_answers(pick(issues), ins, before(issues), now, resp), "responders": len(set(resp) - set(ins))},
221
+ "left_out": {"accounts": len({r["au"] for r in left}), "items": len(left)} if left else None,
222
+ "discussions": _discussions(pick(discussions or []), ins, before(discussions or []), now, resp)}
223
+
224
+
225
+ def _discussions(items: list[dict], ins: set, earlier: int, now: datetime, resp: set) -> dict | None:
226
+ """Outside discussions answered by the project (the issue rule), and how many questions got an accepted answer from
227
+ anyone (a peer's answer still helped the asker)."""
228
+ if not items:
229
+ return None
230
+ asked = [r for r in items if r.get("qa")]
231
+ return {**_answers(items, ins, earlier, now, resp),
232
+ "answer_chosen": round(sum(1 for r in asked if r.get("ans")) / len(asked), 4) if asked else None}
233
+
234
+
235
+ def weekly_changes(prs: list[dict], issues: list[dict], now: datetime, ins: set | None = None,
236
+ resp: set | None = None) -> list[dict]:
237
+ """This week's first answers to outside contributors against the four weeks before, judged against how much this
238
+ repo's weeks usually swing."""
239
+ ins = insiders(prs, issues) if ins is None else ins
240
+ resp = set(ins) | regular_responders(prs, issues, ins, now) if resp is None else resp
241
+ skip = bursts(prs, issues, ins)
242
+ items = [(r, answered_at(r, _for(r, ins, resp))) for r in prs + issues
243
+ if r.get("cr") and not r.get("draft") and outside(r, ins, skip)]
244
+
245
+ def week(t: datetime) -> list[dict]:
246
+ (ws, we), (bs, be) = changes.windows(t)
247
+ # Answers after the week being judged hadn't happened yet then.
248
+ waits = lambda a, b: [_hours(r["cr"], x) for r, x in items if a <= r["cr"] < b and x and x < we] # noqa: E731
249
+ return [changes.times("community.first_answer", "first answer to outside contributors", waits(ws, we), waits(bs, be),
250
+ better=-1, unit="h")]
251
+ return changes.annotate(changes.calibrate(week, now))
252
+
253
+
254
+ def quarterly(prs: list[dict], issues: list[dict], now: datetime, count: int = 4, ins: set | None = None,
255
+ resp: set | None = None) -> list[dict]:
256
+ """The last four calendar quarters (this one so far): outside PRs and issues, how fast the project first answered,
257
+ and how many people contributed here for the first time."""
258
+ ins = insiders(prs, issues) if ins is None else ins
259
+ resp = set(ins) | regular_responders(prs, issues, ins, now) if resp is None else resp
260
+ skip = bursts(prs, issues, ins)
261
+ year, q, quarters = now.year, (now.month - 1) // 3, []
262
+ for _ in range(count):
263
+ quarters.append((f"{year} Q{q + 1}", f"{year}-{3 * q + 1:02d}-01T00:00:00Z"))
264
+ year, q = (year, q - 1) if q else (year - 1, 3)
265
+ quarters.reverse()
266
+ label = lambda ts: f"{ts[:4]} Q{(int(ts[5:7]) - 1) // 3 + 1}" # noqa: E731
267
+ hi, items = _iso(now - DAY), {k: [] for k, _ in quarters}
268
+ for r in prs + issues:
269
+ cr = r.get("cr") or ""
270
+ if cr and cr < hi and not r.get("draft") and outside(r, ins, skip) and label(cr) in items:
271
+ items[label(cr)].append(r)
272
+ new_from = _iso(_dt(covered_since(prs, issues) or _iso(now)) + timedelta(days=HISTORY_DAYS))
273
+ firsts = Counter(label(xs[0]["cr"]) for xs in _contributions(prs, issues, ins, skip).values() if xs[0]["cr"] >= new_from)
274
+ out = []
275
+ for k, start in quarters:
276
+ a = _answers(items[k], ins, 0, resp=resp)
277
+ # Before the history can tell a newcomer from an earlier contributor, the quarter has no newcomer count.
278
+ out.append({"quarter": k, "items": a["items"], "median_hours": a["median_hours"], "within_1d": a["within_1d"],
279
+ "newcomers": firsts[k] if start >= new_from else None})
280
+ return out
281
+
282
+
283
+ def newcomers_waiting(prs: list[dict], issues: list[dict], now: datetime, ins: set | None = None,
284
+ resp: set | None = None) -> list[dict]:
285
+ """Open PRs and issues that are someone's first contribution here and that nobody answered for the project, oldest
286
+ first. GitHub's CONTRIBUTOR association marks people who contributed before our history began."""
287
+ ins, out = (insiders(prs, issues) if ins is None else ins), []
288
+ resp = set(ins) | regular_responders(prs, issues, ins, now) if resp is None else resp
289
+ # Only items inside the complete history can be called someone's first, and only threads we see whole are unanswered.
290
+ since = covered_since(prs, issues) or ""
291
+ for login, items in _contributions(prs, issues, ins, bursts(prs, issues, ins)).items():
292
+ first = items[0]
293
+ seen_whole = len(first.get("cm") or []) < 10 and (first.get("cmn") or 0) <= len(first.get("cm") or [])
294
+ if first.get("st") == "OPEN" and not first.get("draft") and first.get("as") in FIRST_TIMERS and first["cr"] >= since \
295
+ and seen_whole and answered_at(first, _for(first, ins, resp)) is None:
296
+ out.append({"kind": first.get("t") or "pr", "n": first["n"], "created": first["cr"],
297
+ "days": (now - _dt(first["cr"])).days})
298
+ return sorted(out, key=lambda w: w["created"])
299
+
300
+
301
+ def bench_facts(prs: list[dict], now: datetime) -> dict:
302
+ """This repo's community facts with exactly the crawl benchmarks' definitions (PRs only; reviews, merges, closes)."""
303
+ ins = insiders(prs, [])
304
+ skip = bursts(prs, [], ins)
305
+ lo, hi = _iso(now - timedelta(days=90)), _iso(now - DAY)
306
+ recent = [r for r in prs if outside(r, ins, skip) and lo <= (r.get("cr") or "") < hi]
307
+ waits = [(_hours(r["cr"], a) if (a := answered_at(r, comments=False)) else None) for r in recent]
308
+ enough = len(recent) >= MIN_PRS
309
+ new_from = _iso(_dt(covered_since(prs, []) or _iso(now)) + timedelta(days=HISTORY_DAYS))
310
+ seen_until, judged_until = _iso(now - timedelta(days=14)), _iso(now - timedelta(days=RETURN_DAYS + 30))
311
+ firsts = [items for items in _contributions(prs, [], ins, skip).values() if items[0]["cr"] >= new_from]
312
+ seen = [items[0] for items in firsts if items[0]["cr"] <= seen_until]
313
+ judged = [items for items in firsts if items[0]["cr"] <= judged_until]
314
+ answered = [f for f in seen if (a := answered_at(f, comments=False)) and _hours(f["cr"], a) <= 14 * 24]
315
+ back = [items for items in judged if _came_back(items)]
316
+ return {"first_answer_hours": _median([w for w in waits if w is not None]) if enough else None,
317
+ "answered_1d_share": round(sum(1 for w in waits if w is not None and w <= 24) / len(recent), 4) if enough else None,
318
+ "newcomer_answered_share": round(len(answered) / len(seen), 4) if len(seen) >= MIN_NEWCOMERS else None,
319
+ "newcomer_return_rate": round(len(back) / len(judged), 4) if len(judged) >= MIN_NEWCOMERS else None}
320
+
321
+
322
+ def dependency(prs: list[dict], now: datetime, days: int = DEPENDENCY_DAYS) -> dict | None:
323
+ """Who carries the merged work of the last 180 days, without names: how many people, the top person's share and the
324
+ bus factor (CHAOSS: the fewest people behind half of it). Bots and AI agents don't count; None under 20 merged PRs."""
325
+ since = _iso(now - timedelta(days=days))
326
+ counts = Counter(r["au"] for r in prs if r.get("mg") and r["mg"] >= since and _human(r.get("au"), r.get("at")))
327
+ total = sum(counts.values())
328
+ if total < MIN_MERGED:
329
+ return None
330
+ ranked, running, bus = sorted(counts.values(), reverse=True), 0, 0
331
+ for n in ranked:
332
+ running, bus = running + n, bus + 1
333
+ if running * 2 >= total:
334
+ break
335
+ return {"days": days, "merged": total, "people": len(counts), "top_share": round(ranked[0] / total, 4), "bus_factor": bus}
336
+
337
+
338
+ def elephant(prs: list[dict], affiliation: dict[str, str | None], now: datetime, days: int = DEPENDENCY_DAYS) -> dict | None:
339
+ """The organisations behind the merged work (CHAOSS elephant factor: the fewest behind half of it) and their shares;
340
+ work by people with no known organisation is shown as unknown, and no factor is claimed when it is most of it."""
341
+ from stackvitals.community.affiliation import key
342
+ since = _iso(now - timedelta(days=days))
343
+ people = Counter(r["au"] for r in prs if r.get("mg") and r["mg"] >= since and _human(r.get("au"), r.get("at")))
344
+ total = sum(people.values())
345
+ if total < MIN_MERGED:
346
+ return None
347
+ orgs, members = Counter(), Counter()
348
+ for login, n in people.items():
349
+ org = affiliation.get(key(login)) or None
350
+ orgs[org] += n
351
+ members[org] += 1
352
+ unknown = orgs.pop(None, 0)
353
+ known = total - unknown
354
+ factor = None
355
+ if known * 2 > total:
356
+ running = 0
357
+ for factor, (_org, n) in enumerate(orgs.most_common(), 1):
358
+ running += n
359
+ if running * 2 >= known:
360
+ break
361
+ return {"days": days, "merged": total, "people": len(people), "factor": factor,
362
+ "unknown_share": round(unknown / total, 4),
363
+ # Organisations of one person are named together, never alone: a single name would point at that person.
364
+ "orgs": [{"org": o, "prs": n, "share": round(n / total, 4)} for o, n in orgs.most_common() if members[o] > 1][:5]
365
+ + ([{"org": "organisations of one person", "prs": solo, "share": round(solo / total, 4)}]
366
+ if (solo := sum(n for o, n in orgs.items() if members[o] == 1)) else [])}
367
+
368
+
369
+ def cohorts(prs: list[dict], issues: list[dict], now: datetime, ins: set | None = None, months: int = 12) -> dict:
370
+ """Newcomers by the month they first contributed, and how many came back within 90 days and between 90 and 180 days
371
+ (a month too recent to judge says None)."""
372
+ ins = insiders(prs, issues) if ins is None else ins
373
+ first = (now.year * 12 + now.month - 1) - (months - 1)
374
+ keep = {f"{(first + i) // 12:04d}-{(first + i) % 12 + 1:02d}" for i in range(months)}
375
+ # As in the funnel: the first 60 days of known history can't tell a newcomer from someone who came before.
376
+ new_from = _iso(_dt(covered_since(prs, issues) or _iso(now)) + timedelta(days=HISTORY_DAYS))
377
+ out = {}
378
+ for _login, items in _contributions(prs, issues, ins, bursts(prs, issues, ins)).items():
379
+ month = items[0]["cr"][:7]
380
+ if month not in keep or items[0]["cr"] < new_from:
381
+ continue
382
+ c = out.setdefault(month, {"month": month, "newcomers": 0, "back_by_90d": 0, "back_90_180d": 0})
383
+ c["newcomers"] += 1
384
+ later = [_hours(items[0]["cr"], r["cr"]) / 24 for r in items[1:]]
385
+ c["back_by_90d"] += any(1 <= d <= 90 for d in later)
386
+ c["back_90_180d"] += any(90 < d <= 180 for d in later)
387
+ for c in out.values():
388
+ start = _dt(c["month"] + "-01T00:00:00Z")
389
+ # A cohort is judged once its last day is 90 (or 180) days old.
390
+ end = start + timedelta(days=31)
391
+ if now < end + timedelta(days=90):
392
+ c["back_by_90d"] = None
393
+ if now < end + timedelta(days=180):
394
+ c["back_90_180d"] = None
395
+ return {"months": [out[m] for m in sorted(out)]}
396
+
397
+
398
+ def answers_by_tenure(prs: list[dict], issues: list[dict], now: datetime, days: int = 90, ins: set | None = None,
399
+ resp: set | None = None) -> dict:
400
+ """First answers to outside contributors' PRs and issues of the last `days`, for newcomers (their first contribution
401
+ here) and regulars (they contributed before): how many, the median wait and the share answered within a day."""
402
+ ins = insiders(prs, issues) if ins is None else ins
403
+ resp = set(ins) | regular_responders(prs, issues, ins, now) if resp is None else resp
404
+ skip = bursts(prs, issues, ins)
405
+ lo, hi = _iso(now - timedelta(days=days)), _iso(now - DAY)
406
+ # A repo's first 60 days of history can't tell a newcomer from someone who came before.
407
+ new_from = _iso(_dt(covered_since(prs, issues) or _iso(now)) + timedelta(days=HISTORY_DAYS))
408
+ firsts = {id(items[0]) for items in _contributions(prs, issues, ins, skip).values()}
409
+ waits = {"newcomers": [], "regulars": []}
410
+ for r in prs + issues:
411
+ if not outside(r, ins, skip) or not lo <= (r.get("cr") or "") < hi or r.get("draft"):
412
+ continue
413
+ first = id(r) in firsts
414
+ if first and r["cr"] < new_from:
415
+ continue
416
+ a = answered_at(r, _for(r, ins, resp))
417
+ waits["newcomers" if first else "regulars"].append(_hours(r["cr"], a) if a else None)
418
+ out = {"days": days}
419
+ for group, ws in waits.items():
420
+ got = [w for w in ws if w is not None]
421
+ enough = len(ws) >= MIN_TENURE
422
+ out[group] = {"n": len(ws), "median_h": _median(got) if enough and got else None,
423
+ "answered_1d_share": round(sum(1 for w in got if w <= 24) / len(ws), 4) if enough else None}
424
+ return out
425
+
426
+
427
+ def peer_range(bench: dict | None, language: str | None, stars: int | None, metric: str) -> dict | None:
428
+ """Similar public repos' typical value and middle half for a metric, shown instead of a rank: counts like the bus
429
+ factor tie too often for a rank to mean much."""
430
+ found = _group(bench, language, stars, metric)
431
+ if not found:
432
+ return None
433
+ key, g = found
434
+ q = g[metric]
435
+ return {"group": _label(key), "repos": q.get("repos") or g.get("repos"), "typical": q["p50"], "low": q["p25"], "high": q["p75"]}
436
+
437
+
438
+ def _ok(q) -> bool:
439
+ return isinstance(q, dict) and all(isinstance(q.get(f"p{int(r * 100)}"), (int, float)) for r in RANKS)
440
+
441
+
442
+ def _group(bench: dict | None, language: str | None, stars: int | None, metric: str) -> tuple[str, dict] | None:
443
+ groups = (bench or {}).get("groups") if isinstance(bench, dict) else None
444
+ if not isinstance(groups, dict):
445
+ return None
446
+ size = tier(stars)
447
+ for key in (f"{language}|{size}", f"{language}|all", f"all|{size}", "all|all"):
448
+ g = groups.get(key)
449
+ if isinstance(g, dict) and _ok(g.get(metric)):
450
+ return key, g
451
+ return None
452
+
453
+
454
+ def score(facts: dict, bench: dict | None, language: str | None, stars: int | None) -> dict | None:
455
+ """0-100: on average, the share of similar repos this one does better than, over the facts it has; each fact is shown,
456
+ a missing one is left out (never scored as zero), and fewer than two facts give no score."""
457
+ found = _group(bench, language, stars, "first_answer_hours") or _group(bench, language, stars, "newcomer_answered_share")
458
+ if not found:
459
+ return None
460
+ key, g = found
461
+ shown, left_out = [], []
462
+ for name, label, kind, better in SCORE_FACTS:
463
+ if facts.get(name) is None or not _ok(g.get(name)):
464
+ left_out.append(name)
465
+ continue
466
+ r = _rank(facts[name], g[name])
467
+ beats = 1 - r if better < 0 else r
468
+ # Whole percents, and the score is exactly their average: anyone can recompute it from the page.
469
+ shown.append({"key": name, "name": label, "kind": kind, "you": facts[name], "typical": g[name]["p50"],
470
+ "better_than": int(beats * 100 + 0.5)})
471
+ if len(shown) < 2:
472
+ return None
473
+ return {"score": int(sum(f["better_than"] for f in shown) / len(shown) + 0.5), "group": _label(key), "repos": g["repos"],
474
+ "built_at": (bench or {}).get("built_at"), "facts": shown, "left_out": left_out}
475
+
476
+
477
+ def star_trend(weeks: dict, stars: int | None, now: datetime, bench: dict | None = None,
478
+ language: str | None = None) -> dict | None:
479
+ """Weekly stars (GitHub's history, no names), the gain over the last 12 complete weeks and its place among peers."""
480
+ rows = [{"week": int(k), "total": int(v)} for k, v in (weeks or {}).items() if str(k).isdigit()]
481
+ if not rows:
482
+ return None
483
+ series = [[datetime.fromtimestamp(r["week"], now.tzinfo).strftime("%Y-%m-%d"), r["total"]]
484
+ for r in sorted(rows, key=lambda r: r["week"])][-52:]
485
+ out = {"weeks": series, "stars": stars, **(star_stats(rows, stars, now) or {"gained_12w": None, "growth_12w": None})}
486
+ found = _group(bench, language, stars, "growth_12w") if out["growth_12w"] is not None and (stars or 0) >= 100 else None
487
+ if found:
488
+ key, g = found
489
+ r = _rank(out["growth_12w"], g["growth_12w"])
490
+ place = f"above about {_about(r)}%" if r >= 0.5 else f"below about {_about(1 - r)}%"
491
+ out["peers"] = {"group": _label(key), "repos": g["repos"], "place": place,
492
+ "typical_gain": (g.get("gained_12w") or {}).get("p50")}
493
+ return out
494
+
495
+
496
+ def traffic_summary(traffic: dict, now: datetime, days: int = 90) -> dict | None:
497
+ """Views and clones per day (kept past GitHub's 14 days) and the latest top referrers."""
498
+ if not traffic or not traffic.get("views"):
499
+ return None
500
+ lo = (now - timedelta(days=days)).strftime("%Y-%m-%d")
501
+ pick = lambda key: sorted([d, *v] for d, v in (traffic.get(key) or {}).items() if d >= lo) # noqa: E731
502
+ views, clones = pick("views"), pick("clones")
503
+ latest = max(traffic.get("referrers") or {"": []}, default="")
504
+ return {"days": days, "since": min(traffic["views"]), "views": views, "clones": clones,
505
+ "views_total": sum(v[1] for v in views), "visitors_total": sum(v[2] for v in views),
506
+ "clones_total": sum(v[1] for v in clones),
507
+ "referrers": (traffic.get("referrers") or {}).get(latest, []), "referrers_day": latest or None}
@@ -0,0 +1,56 @@
1
+ from datetime import datetime
2
+
3
+ from stackvitals.community.analyze import (answers_by_tenure, bench_facts, cohorts, dependency, elephant, first_answer,
4
+ funnel, insiders,
5
+ newcomers_waiting, peer_range, quarterly, regular_responders, score, star_trend,
6
+ traffic_summary, weekly_changes)
7
+ from stackvitals.connectors.discourse import answers as forum_answers
8
+ from stackvitals.core.time import iso
9
+
10
+ WAITING_ROWS = 50
11
+
12
+
13
+ def _lever(bench: dict | None) -> dict | None:
14
+ # The crawl's newcomer table: what happened to first-timers across public repos, by how long they waited.
15
+ table = (bench or {}).get("newcomers") if isinstance(bench, dict) else None
16
+ if not isinstance(table, dict) or not isinstance(table.get("by_answer"), list) or not table.get("newcomers"):
17
+ return None
18
+ rows = [b for b in table["by_answer"] if isinstance(b, dict) and isinstance(b.get("returned"), (int, float))
19
+ and isinstance(b.get("newcomers"), int) and isinstance(b.get("bucket"), str)]
20
+ outcome = [b for b in table.get("by_outcome") or [] if isinstance(b, dict) and isinstance(b.get("returned"), (int, float))]
21
+ return {"newcomers": table["newcomers"], "repos": table.get("repos"), "by_answer": rows, "by_outcome": outcome} if rows else None
22
+
23
+
24
+ def build(prs: dict, issues: dict, stars: dict, traffic: dict, repo: dict, now: datetime, bench: dict | None = None,
25
+ star_bench: dict | None = None, context: dict | None = None, affiliation: dict | None = None,
26
+ discussions: dict | None = None, forum: list[dict] | None = None, chat: list[dict] | None = None) -> dict:
27
+ """Everything Community shows, from Team Flow's PR history, the issues, the star history and (if kept) traffic."""
28
+ all_prs = (prs.get("open") or []) + (prs.get("closed") or [])
29
+ all_issues = (issues.get("open") or []) + (issues.get("closed") or [])
30
+ language, star_count = repo.get("language"), repo.get("stars")
31
+ # Who the maintainers are is decided once for the whole page.
32
+ ins = insiders(all_prs, all_issues)
33
+ # Issues count an answer from a maintainer or a regular responder; PRs from a maintainer.
34
+ resp = set(ins) | regular_responders(all_prs, all_issues, ins, now)
35
+ facts = bench_facts(all_prs, now)
36
+ waiting = newcomers_waiting(all_prs, all_issues, now, ins, resp)
37
+ carried = dependency(all_prs, now)
38
+ if carried:
39
+ # Similar public repos' typical values beside this repo's, not folded into the score (it keeps one meaning).
40
+ carried["peers"] = {m: peer_range(bench, language, star_count, m) for m in ("bus_factor", "top_share")}
41
+ return {"generated": iso(now), "facts": facts, "score": score(facts, bench, language, star_count),
42
+ "has_bench": bool(bench),
43
+ "funnel": funnel(all_prs, all_issues, now, ins),
44
+ "answers": first_answer(all_prs, all_issues, now, ins=ins, resp=resp,
45
+ discussions=(discussions or {}).get("open", []) + (discussions or {}).get("closed", [])),
46
+ "waiting": waiting[:WAITING_ROWS], "waiting_total": len(waiting), "lever": _lever(bench),
47
+ "stars": star_trend((stars or {}).get("weeks") or {}, star_count, now, star_bench, language),
48
+ "traffic": traffic_summary(traffic or {}, now),
49
+ "quarters": quarterly(all_prs, all_issues, now, ins=ins, resp=resp),
50
+ "dependency": carried, "tenure": answers_by_tenure(all_prs, all_issues, now, ins=ins, resp=resp),
51
+ "changes": weekly_changes(all_prs, all_issues, now, ins, resp),
52
+ # What the repo states about itself (read weekly), the organisations behind the work, and newcomer cohorts.
53
+ "context": context, "orgs": elephant(all_prs, affiliation, now) if affiliation is not None else None,
54
+ "cohorts": cohorts(all_prs, all_issues, now, ins),
55
+ # A Discourse forum, when connected: questions answered there instead of in issues.
56
+ "forum": forum_answers(forum, now) if forum else None, "chat": forum_answers(chat, now) if chat else None}
@@ -0,0 +1,70 @@
1
+ """StackVitals' numbers beside the CHAOSS metrics they answer (chaoss.community, 89 metrics read 2026-10-23): which
2
+ function computes each, and where ours differs and why. docs/chaoss.md is built from this list, and a test checks every
3
+ function named here exists, so the page can't point at code that is gone."""
4
+
5
+ KB = "https://chaoss.community/kb/metric-"
6
+
7
+ # CHAOSS metric, its page slug, our function, how ours differs (empty when the definitions agree)
8
+ MAPPING = [
9
+ ("Time to First Response", "time-to-first-response", "stackvitals.community.analyze.first_answer",
10
+ "Only answers from the project count (maintainers, people who triage, regular responders, or a close), not the "
11
+ "first comment from anyone; issues still waiting stay in the median (Kaplan-Meier) instead of dropping out. On "
12
+ "labelled public issues, 46% of the first comments other tools count are drive-bys (bench/accuracy/community)."),
13
+ ("Issue Response Time", "issue-response-time", "stackvitals.community.analyze.first_answer",
14
+ "The same rule as the first response above, for issues."),
15
+ ("Newcomer Experience", "newcomer-experience", "stackvitals.community.analyze.answers_by_tenure",
16
+ "Measured, not surveyed: how fast first-time contributors are answered against people who contributed before."),
17
+ ("New Contributors", "new-contributors", "stackvitals.community.analyze.funnel",
18
+ "Bots, AI agents and accounts opening 20 or more items in a day are left out; the funnel also says how many came "
19
+ "back within 90 days (CHAOSS has no single retention metric; Inactive Contributors is the nearest)."),
20
+ ("Contributor Absence Factor", "contributor-absence-factor", "stackvitals.community.analyze.dependency",
21
+ "The same definition (the fewest people behind half of the merged work, formerly Bus Factor) over 180 days, with "
22
+ "bots and AI agents left out and similar public repos' middle half shown instead of a rank."),
23
+ ("Elephant Factor", "elephant-factor", "stackvitals.community.analyze.elephant",
24
+ "Organisations from the company on people's public profiles; people without one are counted as unknown and shown, "
25
+ "never guessed. The factor is over the work with a known organisation, and none is claimed when most is unknown."),
26
+ ("Organizational Diversity", "organizational-diversity", "stackvitals.community.analyze.elephant",
27
+ "The share of merged work by organisation, unknown shown."),
28
+ ("Project Popularity", "project-popularity", "stackvitals.community.analyze.star_trend",
29
+ "Weekly stars kept over time and placed among similar repos, so a launch spike reads as one."),
30
+ ("Clones", "clones", "stackvitals.community.analyze.traffic_summary",
31
+ "Kept past GitHub's 14 days when a traffic token is set."),
32
+ ("Change Request Acceptance Ratio", "change-request-acceptance-ratio", "stackvitals.flow.analyze.analyze",
33
+ "Outsiders' pull requests only (insiders merge their own), with its sample size and range."),
34
+ ("Change Request Review Duration", "change-request-review-duration", "stackvitals.flow.analyze.stages",
35
+ "Split into waiting for a first review and the review itself, counted from when the PR was ready, not opened."),
36
+ ("Change Requests Duration", "change-requests-duration", "stackvitals.core.delivery.phases",
37
+ "LinearB's four phases (coding, pickup, review, deploy), with review split into its time to approve and time to merge, "
38
+ "from first commit to deployment."),
39
+ ("Release Frequency", "release-frequency", "stackvitals.core.delivery.dora",
40
+ "Counted as DORA's deployment frequency from the source the repo really ships with (deployments, deploy "
41
+ "workflows or releases), with the source shown."),
42
+ ("Time to Close", "time-to-close", "stackvitals.connectors.tracker.lead_times",
43
+ "From Jira or Linear when connected: created to done, by issue type."),
44
+ ("Labor Investment", "labor-investment", "stackvitals.core.investment.mix",
45
+ "Where merged work went each month (feature, fix, maintenance, security, docs), with how each PR was sorted."),
46
+ ("Test Coverage", "test-coverage", "stackvitals.ci.analyze._coverage",
47
+ "From the coverage reports CI already uploads, with the change on each pull request."),
48
+ ("Upstream Code Dependencies", "upstream-code-dependencies", "stackvitals.deps.supply.sbom",
49
+ "From GitHub's dependency graph, with upgrade evidence from public repos for each package."),
50
+ ("SPDX Document", "spdx-document", "stackvitals.deps.supply.parse_spdx",
51
+ "Read from the SPDX SBOM GitHub generates; StackVitals doesn't write one."),
52
+ ("Code of Conduct for a Project", "code-of-conduct-for-a-project", "stackvitals.portal.inventory.repo_files",
53
+ "Present or missing, per repo in the Portal catalog."),
54
+ ("Bot Activity", "bot-activity", "stackvitals.core.actors.kind_of",
55
+ "Bots and AI agents are found in every module and counted apart from people, never mixed into people's numbers."),
56
+ ]
57
+
58
+
59
+ def markdown() -> str:
60
+ lines = ["# StackVitals and CHAOSS", "",
61
+ "[CHAOSS](https://chaoss.community) defines open source community health metrics. Each row below is a "
62
+ "CHAOSS metric StackVitals answers, the code that computes it, and where ours differs and why. The list is "
63
+ "built from `stackvitals/community/chaoss.py`, and a test checks every function it names exists.", "",
64
+ "| CHAOSS metric | Computed by | How StackVitals differs |", "|---|---|---|"]
65
+ for name, slug, where, differs in MAPPING:
66
+ lines.append(f"| [{name}]({KB}{slug}/) | `{where.removeprefix('stackvitals.')}` | {differs or 'Same definition.'} |")
67
+ lines += ["", f"{len(MAPPING)} of CHAOSS's 89 metrics. Most of the rest are about events, governance documents and "
68
+ "surveys, which no repo's history can measure; the community page shows a repo's stated policies "
69
+ "(governance, funding, response times) beside its numbers instead.", ""]
70
+ return "\n".join(lines)