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
stackvitals/cli.py ADDED
@@ -0,0 +1,111 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+
5
+ MODULES = {
6
+ "ci": "CI health: flaky and slow jobs (`stackvitals ci report owner/repo`)",
7
+ "flow": "Team Flow: who acts next on pull requests (`stackvitals flow report owner/repo`)",
8
+ "deps": "Dependency Steward: should you add or upgrade a package (`stackvitals deps check requests`)",
9
+ "portal": "Portal: what an org owns, who owns it, and its shape (`stackvitals portal report my-org`)",
10
+ "community": "Community: where newcomers drop out and how fast they get a first answer (`stackvitals community report owner/repo`)",
11
+ "contribute": "Contribute: open-source issues you can get merged, and how changes get merged in a repo (`stackvitals contribute brief owner/repo`)",
12
+ }
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ argv = list(sys.argv[1:] if argv is None else argv)
17
+ parser = argparse.ArgumentParser(prog="stackvitals", description="Free, open-source developer suite.")
18
+ sub = parser.add_subparsers(dest="module", required=True)
19
+ for name, text in MODULES.items():
20
+ sub.add_parser(name, add_help=False, help=text)
21
+ sub.add_parser("action", add_help=False, help="Run inside GitHub Actions (used by the StackVitals Action)")
22
+ serve = sub.add_parser("serve", help="Run the MCP server on stdio (MergeScout and CI Health tools)")
23
+ serve.add_argument("--tools", choices=["core", "all"], help="core: the 11 core tools (default); all: every tool "
24
+ "(or set STACKVITALS_MCP_TOOLS=all)")
25
+ sub.add_parser("record", add_help=False, help="Send a deployment or incident from any tool (`stackvitals record deployment owner/repo --id 42 --sha abc1234`)")
26
+ sub.add_parser("project", add_help=False, help="DORA for one project across several repos, as DevLake counts a project (`stackvitals project web org/a org/b`)")
27
+ sub.add_parser("delivery", add_help=False, help="Cycle time, DORA and stuck PRs (`stackvitals delivery owner/repo`)")
28
+ sub.add_parser("ui", add_help=False, help="Open a repo's pages, Explore included, from a local copy (`stackvitals ui owner/repo`)")
29
+ sub.add_parser("explain", add_help=False, help="What a headline number was counted from (`stackvitals explain owner/repo lead-time`)")
30
+ sub.add_parser("export", add_help=False, help="A repo's data as DevLake tables or a CTRF test report (`stackvitals export owner/repo --format devlake|ctrf`)")
31
+ sub.add_parser("insights", add_help=False, help="What to fix first, and how long merged PRs sat with failing checks (`stackvitals insights owner/repo`)")
32
+ sub.add_parser("sql", add_help=False, help="SQL over a repo's summaries (`stackvitals sql --repo owner/name \"select ...\"`)")
33
+ sub.add_parser("propose", add_help=False, help="A link proposing a settings change or decision that a person submits (`stackvitals propose owner/repo --set alerts.pickup_p75_hours=6`)")
34
+ sub.add_parser("site", add_help=False, help="Build the project site: landing page, the app and its demo (`stackvitals site build --out DIR`)")
35
+ sub.add_parser("connectors", add_help=False, help="Test each connector's key (`stackvitals connectors check jira slack`, or --all)")
36
+ sub.add_parser("editor", add_help=False, help="Mark flaky tests in your editor (`stackvitals editor vscode|cursor|vscodium`)")
37
+ # Module commands parse their own arguments.
38
+ args, rest = parser.parse_known_args(argv)
39
+ if args.module == "ci":
40
+ from stackvitals.ci.cli import main as ci_main
41
+ return ci_main(rest)
42
+ if args.module == "flow":
43
+ import stackvitals.flow.cli as flow_cli
44
+ return flow_cli.main(rest)
45
+ if args.module == "deps":
46
+ from stackvitals.deps.cli import main as deps_main
47
+ return deps_main(rest)
48
+ if args.module == "portal":
49
+ from stackvitals.portal.cli import main as portal_main
50
+ return portal_main(rest)
51
+ if args.module == "community":
52
+ from stackvitals.community.cli import main as community_main
53
+ return community_main(rest)
54
+ if args.module == "action":
55
+ from stackvitals.ci.cli import action_main
56
+ return action_main(rest)
57
+ if args.module == "ui":
58
+ from stackvitals.core.uicli import main as ui_main
59
+ return ui_main(rest)
60
+ if args.module == "explain":
61
+ from stackvitals.core.explaincli import main as explain_main
62
+ return explain_main(rest)
63
+ if args.module == "export":
64
+ from stackvitals.core.exportcli import main as export_main
65
+ return export_main(rest)
66
+ if args.module == "project":
67
+ from stackvitals.core.projectcli import main as project_main
68
+ return project_main(rest)
69
+ if args.module == "record":
70
+ from stackvitals.core.recorded import main as record_main
71
+ return record_main(rest)
72
+ if args.module == "delivery":
73
+ from stackvitals.core.deliverycli import main as delivery_main
74
+ return delivery_main(rest)
75
+ if args.module == "insights":
76
+ from stackvitals.core.insightscli import main as insights_main
77
+ return insights_main(rest)
78
+ if args.module == "propose":
79
+ from stackvitals.core.propose import main as propose_main
80
+ return propose_main(rest)
81
+ if args.module == "site":
82
+ from stackvitals.site import main as site_main
83
+ return site_main(rest)
84
+ if args.module == "editor":
85
+ from stackvitals.editors import main as editor_main
86
+ return editor_main(rest)
87
+ if args.module == "connectors":
88
+ from stackvitals.connectors.check import main as connectors_main
89
+ return connectors_main(rest)
90
+ if args.module == "sql":
91
+ from stackvitals.core.sqlcli import main as sql_main
92
+ return sql_main(rest)
93
+ if args.module == "serve":
94
+ import logging
95
+
96
+ from stackvitals.mcp_server import build_server
97
+ # Request lines from the HTTP library would only clutter the AI client's log.
98
+ logging.getLogger("httpx").setLevel(logging.WARNING)
99
+ # The 11 core tools by default; `--tools all` (or STACKVITALS_MCP_TOOLS=all) loads every one.
100
+ tools = args.tools or os.environ.get("STACKVITALS_MCP_TOOLS", "core") # the command line wins over the environment
101
+ build_server(tools="all" if tools == "all" else "core").run("stdio")
102
+ return 0
103
+ if rest[:1] == ["brief"]:
104
+ import stackvitals.contribute.cli as brief_cli
105
+ return brief_cli.main(rest[1:])
106
+ import mergescout.cli as mergescout_cli
107
+ return mergescout_cli.main(rest)
108
+
109
+
110
+ if __name__ == "__main__":
111
+ raise SystemExit(main())
@@ -0,0 +1,69 @@
1
+ """CODEOWNERS read the way GitHub reads it, shared by CI Health, Team Flow and Portal: gitignore-style patterns, the last
2
+ matching line wins, a line without owners leaves its paths without owners."""
3
+ import re
4
+ from functools import lru_cache
5
+
6
+ # GitHub's lookup order: the first file found is the one it uses.
7
+ PATHS = (".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS")
8
+
9
+
10
+ def parse(text: str | None, skip=frozenset()) -> list[tuple[str, list[str]]]:
11
+ """(pattern, owners) per rule line; comments, blank lines, "!" lines (unsupported by GitHub) and the line numbers in
12
+ `skip` (lines GitHub's error check calls invalid, which GitHub skips) are left out."""
13
+ rules = []
14
+ for n, raw in enumerate((text or "").lstrip("\ufeff").splitlines(), 1):
15
+ line = raw.strip()
16
+ if n in skip:
17
+ continue
18
+ if not line or line.startswith(("#", "!")):
19
+ continue
20
+ # A space inside a path is written "\ ".
21
+ pattern, *rest = re.split(r"(?<!\\)\s+", line)
22
+ owners = []
23
+ for token in rest:
24
+ if token.startswith("#"):
25
+ break
26
+ owners.append(token)
27
+ rules.append((pattern.replace("\\ ", " "), owners))
28
+ return rules
29
+
30
+
31
+ def _glob(segment: str) -> str:
32
+ return "".join("[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c) for c in segment)
33
+
34
+
35
+ @lru_cache(maxsize=65536)
36
+ def _regex(pattern: str) -> re.Pattern:
37
+ # A slash at the start or in the middle ties the pattern to the repo root; otherwise it matches at any depth.
38
+ anchored = pattern.startswith("/") or "/" in pattern.strip("/")
39
+ segs = pattern.strip("/").split("/")
40
+ rx = "" if anchored else "(?:.*/)?"
41
+ for i, seg in enumerate(segs):
42
+ first, last = i == 0, i == len(segs) - 1
43
+ if seg == "**":
44
+ rx += (".*" if first else "/.*") if last else ("(?:.*/)?" if first else "/(?:.*/)?")
45
+ continue
46
+ if not first and segs[i - 1] != "**":
47
+ rx += "/"
48
+ rx += _glob(seg)
49
+ if segs[-1] == "**":
50
+ pass
51
+ elif pattern.endswith("/"):
52
+ rx += "/.*" # a folder: everything inside it
53
+ elif not (segs[-1] == "*" and len(segs) > 1):
54
+ rx += "(?:/.*)?" # a file, or a folder and everything inside it; "docs/*" stays one level deep
55
+ return re.compile(rx)
56
+
57
+
58
+ def matches(pattern: str, path: str) -> bool:
59
+ return bool(_regex(pattern).fullmatch(path.lstrip("/")))
60
+
61
+
62
+ def owners_for(path: str, rules: list[tuple[str, list[str]]], unknown=frozenset()) -> list[str]:
63
+ """The owners GitHub would request for this path, as written (@user, @org/team, email). Owners GitHub ignores
64
+ (`unknown`, lowercase, from its error check) are left out; the matching line still decides, as on GitHub."""
65
+ found = []
66
+ for pattern, owners in rules:
67
+ if matches(pattern, path):
68
+ found = owners
69
+ return [o for o in found if o.lower() not in unknown]
File without changes
@@ -0,0 +1,140 @@
1
+ import json
2
+ from collections import Counter
3
+ from datetime import datetime, timedelta, timezone
4
+ from pathlib import Path
5
+
6
+ from stackvitals.ci import bench as ci_bench
7
+ from stackvitals.ci.action import BRANCH, DataBranch, GitError
8
+ from stackvitals.ci.api import ActionsAPI, APIError, OutOfBudget
9
+ from stackvitals.community import affiliation
10
+ from stackvitals.connectors.store import ConnectorStore
11
+ from stackvitals.community.build import build
12
+ from stackvitals.community.context import read_context
13
+ from stackvitals.community.collect import _read, collect_discussions, collect_issues, collect_stars, collect_traffic
14
+ from stackvitals.community.report import badges, render_html, render_markdown
15
+ from stackvitals.core.store import published
16
+ from stackvitals.flow.action import FLOW_EVENTS
17
+ from stackvitals.flow.action import LABEL as FLOW_LABEL
18
+ from stackvitals.flow.action import make_gql
19
+ from stackvitals.flow.collect import collect
20
+ from stackvitals.flow.store import FlowStore
21
+ from stackvitals import shell
22
+ from stackvitals.core.actors import kind_of
23
+ from stackvitals.core.config import parse
24
+ from stackvitals.core.delivery import apply_identity
25
+
26
+ BENCH_FILE, STARS_FILE = "community-benchmarks.json", "stars-benchmarks.json"
27
+ LABEL = "Community"
28
+
29
+
30
+ def load_benchmarks(cache_dir: Path, token: str | None = None) -> tuple[dict | None, dict | None]:
31
+ # In the Action: public files only (none before launch). Locally, the user's own token may read them.
32
+ ok = lambda d: d if isinstance(d, dict) and isinstance(d.get("groups"), dict) else None # noqa: E731
33
+ return (ci_bench.load(token, cache_dir, name=BENCH_FILE, url_env="STACKVITALS_COMMUNITY_BENCH_URL", validate=ok, default_url=None),
34
+ ci_bench.load(token, cache_dir, name=STARS_FILE, url_env="STACKVITALS_STARS_BENCH_URL", validate=ok, default_url=None))
35
+
36
+
37
+ def write_community_outputs(root: Path, result: dict, repo: str) -> None:
38
+ docs = root / "docs"
39
+ docs.mkdir(parents=True, exist_ok=True)
40
+ (docs / "community.html").write_text(render_html(result, repo, shell.present(docs) | {"community.html"}), encoding="utf-8")
41
+ (docs / "community.json").write_text(json.dumps(published(result), indent=1), encoding="utf-8")
42
+ for name, badge in badges(result).items():
43
+ (docs / name).write_text(json.dumps(badge), encoding="utf-8")
44
+ (docs / ".nojekyll").write_text("", encoding="utf-8")
45
+
46
+
47
+ def community_step(ws, env: dict, now: datetime, make_gql=make_gql, make_api=ActionsAPI, flow_ran: bool | None = None) -> dict:
48
+ """Community's part of a job: issues, star history and (with its own token) traffic into the shared checkout,
49
+ beside Team Flow's PR history; the page, the badges and (after the commit) the summary."""
50
+ repo, token = ws.repo, env.get("STACKVITALS_TOKEN") or env.get("GITHUB_TOKEN")
51
+ traffic_token = (env.get("STACKVITALS_TRAFFIC_TOKEN") or "").strip()
52
+ owner, name = repo.split("/")
53
+ if flow_ran is None:
54
+ # Team Flow ran earlier in this same job: its PRs are already in the checkout.
55
+ flow_ran = ws.results.get(FLOW_LABEL) is not None
56
+ notes = []
57
+ prs, gql = FlowStore(ws.path), make_gql(token)
58
+ # Team Flow already read the PRs this run; otherwise Community keeps the same history itself.
59
+ points = 0 if flow_ran else collect(gql, prs, owner, name, now)["points"]
60
+ issues = FlowStore(ws.path, "community/issues")
61
+ got = collect_issues(gql, issues, owner, name, now)
62
+ if got.get("refused"):
63
+ notes.append("Add `issues: read` to the workflow permissions to include issues.")
64
+ folder = ws.path / "community"
65
+ try:
66
+ collect_stars(make_api(token, repo, max_requests=110), folder, now)
67
+ except (APIError, OutOfBudget) as exc:
68
+ notes.append(f"Star history wasn't read this time ({exc}).")
69
+ if traffic_token:
70
+ try:
71
+ collect_traffic(make_api(traffic_token, repo, max_requests=10), folder, now)
72
+ except (APIError, OutOfBudget) as exc:
73
+ notes.append("The traffic token can't read traffic: it needs the repository permission "
74
+ f"\"Administration: read\" ({str(exc)[:120]}).")
75
+ bench, star_bench = load_benchmarks(ws.work / "bench")
76
+ # The repo's [identity] settings decide who is a bot or a newcomer; the stored history stays as GitHub said.
77
+ settings = ws.cache.get("settings") or parse("")[0]
78
+ ident = settings.get("identity")
79
+ pr_data = apply_identity(prs.load(), ident)
80
+ context = _context(gql, folder, owner, name, now)
81
+ stated = {k: v for k, v in (settings.get("community") or {}).items() if v}
82
+ if stated:
83
+ context = {**(context or {}), "stated": stated}
84
+ talks = FlowStore(ws.path, "community/discussions")
85
+ if (context or {}).get("discussions"):
86
+ points += collect_discussions(gql, talks, owner, name, now)["points"]
87
+ # The people behind the last 180 days' merged work, busiest first, for the elephant factor.
88
+ since = (now - timedelta(days=180)).strftime("%Y-%m-%dT%H:%M:%SZ")
89
+ authors = Counter(p["au"] for p in pr_data.get("closed") or [] if p.get("mg") and p["mg"] >= since and p.get("au")
90
+ and kind_of(p["au"], p.get("at")) == "person")
91
+ aff = affiliation.collect(gql, [a for a, _n in authors.most_common()])
92
+ result = build(pr_data, apply_identity(issues.load(), ident), _read(folder / "stars.json"),
93
+ _read(folder / "traffic.json"), prs.repo(), now, bench=bench, star_bench=star_bench,
94
+ context=context, affiliation=aff, discussions=talks.load(),
95
+ forum=ConnectorStore(ws.path, "discourse").load().get("topic"),
96
+ chat=[t for n in ("slack", "discord") for t in ConnectorStore(ws.path, n).load().get("topic") or []])
97
+ write_community_outputs(ws.path, result, repo)
98
+ out = {"issues": got["issues"], "points": points + got["points"], "complete": got["complete"],
99
+ "waiting": result["waiting_total"], "score": (result.get("score") or {}).get("score")}
100
+
101
+ def after_commit():
102
+ summary = env.get("GITHUB_STEP_SUMMARY")
103
+ if summary:
104
+ note = "" if got["complete"] or got.get("refused") else "\n\nStill reading older issues (GitHub's hourly limit); the next run continues."
105
+ note += "".join(f"\n\n{n}" for n in notes)
106
+ with open(summary, "a", encoding="utf-8") as f:
107
+ f.write(render_markdown(result, repo) + note + "\n")
108
+ ws.after_commit(after_commit, LABEL)
109
+ return out
110
+
111
+
112
+ def _context(gql, folder: Path, owner: str, name: str, now: datetime) -> dict | None:
113
+ """What the repo states about itself, read once a week (a file changes rarely)."""
114
+ kept = _read(folder / "context.json")
115
+ if kept.get("at", "") >= (now - timedelta(days=7)).strftime("%Y-%m-%d"):
116
+ return kept.get("facts")
117
+ facts = read_context(gql, owner, name)
118
+ if facts is None:
119
+ return kept.get("facts")
120
+ folder.mkdir(parents=True, exist_ok=True)
121
+ (folder / "context.json").write_text(json.dumps({"at": now.strftime("%Y-%m-%d"), "facts": facts}, indent=1),
122
+ encoding="utf-8")
123
+ return facts
124
+
125
+
126
+ def run_community_action(env: dict, now: datetime | None = None, make_gql=make_gql, make_api=ActionsAPI,
127
+ flow_ran: bool | None = None, workspace=None) -> dict | None:
128
+ """One Community run on the schedule or by hand: issues, star history and (with its own token) traffic, kept in the
129
+ data branch beside Team Flow's PR history, then the page, the badges and the summary."""
130
+ if env.get("GITHUB_EVENT_NAME") not in FLOW_EVENTS:
131
+ return None
132
+ from stackvitals.core.workspace import run_job
133
+ now = now or datetime.now(timezone.utc)
134
+
135
+ def step(ws):
136
+ return community_step(ws, env, now, make_gql, make_api, bool(flow_ran) if workspace is None else flow_ran)
137
+ if workspace is not None:
138
+ return step(workspace)
139
+ job = run_job(env, [(LABEL, step)], now, message=f"stackvitals: community {now:%Y-%m-%d %H:%M} UTC", catch=False)
140
+ return {**job["results"][LABEL], "pushed": job["outcome"] == "pushed"}
@@ -0,0 +1,51 @@
1
+ """Which organisation the people behind the merged work say they work for, for the elephant factor (CHAOSS): the
2
+ company on their public profile, else unknown (counted and shown, never guessed). Membership of the org that owns the
3
+ repo isn't used: volunteer projects' orgs aren't employers. Looked up each run and kept only in memory, so no person's
4
+ employer is written to the data branch."""
5
+ import hashlib
6
+ import json
7
+ import re
8
+
9
+ from mergescout.github.graphql import GraphQLError, RateLimited
10
+
11
+ BATCH, MAX_PEOPLE = 25, 150
12
+ SUFFIX = re.compile(r"\b(inc|llc|ltd|limited|corp|corporation|gmbh|co|company|plc|sa|ag|bv|oy|ab|the)\b\.?")
13
+ NOT_A_COMPANY = {"freelance", "freelancer", "independent", "self", "self-employed", "selfemployed", "none", "n/a", "na",
14
+ "student", "retired", "personal", "home", "me", "myself", "open source", "opensource", "unemployed"}
15
+
16
+
17
+ def key(login: str) -> str:
18
+ return hashlib.sha1((login or "").lower().encode()).hexdigest()[:12]
19
+
20
+
21
+ def normalize(company: str | None) -> str | None:
22
+ """A company field as one organisation name: "@Google LLC" and "google, inc." are google; self-employment is none."""
23
+ text = (company or "").strip().lower()
24
+ if not text:
25
+ return None
26
+ text = text.split()[0] if text.startswith("@") else text # "@acme @other": the first one
27
+ text = re.split(r"[,/|;(]", text.lstrip("@"))[0]
28
+ text = SUFFIX.sub("", text).strip(" .-_")
29
+ text = re.sub(r"\s+", " ", text)
30
+ return None if not text or text in NOT_A_COMPANY else text
31
+
32
+
33
+ def _query(logins: list[str]) -> str:
34
+ parts = [f"u{i}: user(login: {json.dumps(x)}) {{ login company }}" for i, x in enumerate(logins)]
35
+ return "query { rateLimit { cost remaining resetAt } " + " ".join(parts) + " }"
36
+
37
+
38
+ def collect(gql, logins: list[str]) -> dict[str, str | None]:
39
+ """Each login's organisation (by hash), looked up fresh each run and never written down: a hash of a public login
40
+ can be reversed, so a kept file would name each person's employer. At most 150 people (the busiest first)."""
41
+ out = {}
42
+ for i in range(0, min(len(logins), MAX_PEOPLE), BATCH):
43
+ batch = logins[i:min(i + BATCH, MAX_PEOPLE)]
44
+ try:
45
+ # Partial: a renamed or deleted account answers null instead of failing the whole batch.
46
+ data = gql.query(_query(batch), {}, partial=True).data or {}
47
+ except (GraphQLError, RateLimited):
48
+ break
49
+ for j, login in enumerate(batch):
50
+ out[key(login)] = normalize((data.get(f"u{j}") or {}).get("company"))
51
+ return out