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,264 @@
1
+ """The org's repos and what is known about each: settings, custom properties, branch rules, and the suite's own module
2
+ results read from each repo's stackvitals-data branch (where the suite runs)."""
3
+ import json
4
+ import re
5
+ from datetime import datetime, timedelta
6
+
7
+ import httpx
8
+
9
+ from stackvitals.ci.api import APIError
10
+ from stackvitals.core.actors import INSIDER_ASSOC, People, kind_of
11
+ from stackvitals.core.store import accept
12
+ from stackvitals.portal import descriptor
13
+ from stackvitals.portal.github import NA, REFUSED, read, read_file
14
+
15
+ DATA_BRANCH = "stackvitals-data"
16
+ FILES = ("readme", "license", "contributing", "code_of_conduct")
17
+ SCORECARD_API = "https://api.scorecard.dev/projects/github.com/"
18
+
19
+
20
+ def _repo(r: dict) -> dict:
21
+ return {"name": r["full_name"], "private": bool(r.get("private")), "archived": bool(r.get("archived")),
22
+ "fork": bool(r.get("fork")), "language": r.get("language"), "pushed": r.get("pushed_at"),
23
+ "default_branch": r.get("default_branch"), "topics": r.get("topics") or [], "stars": r.get("stargazers_count"),
24
+ "license": (r.get("license") or {}).get("key")}
25
+
26
+
27
+ def list_repos(api, org: str, mode: str, max_pages: int = 50) -> list[dict]:
28
+ """Private mode: every repo the org's App is installed on. Public mode: the org's public repos (or a user's)."""
29
+ found = []
30
+ for page in range(1, max_pages + 1):
31
+ if mode == "private":
32
+ body = read(api, "/installation/repositories", {"per_page": 100, "page": page})
33
+ batch = [] if body is NA else body.get("repositories") or []
34
+ else:
35
+ # Local runs list what the user's own token can see; the Action's public mode lists public repos only.
36
+ body = read(api, f"/orgs/{org}/repos", {"type": "all" if mode == "local" else "public", "per_page": 100, "page": page})
37
+ if body is NA and page == 1:
38
+ body = read(api, f"/users/{org}/repos", {"type": "owner", "per_page": 100, "page": page})
39
+ batch = [] if body is NA else body
40
+ found += [_repo(r) for r in batch if isinstance(r, dict) and r.get("full_name")]
41
+ if len(batch) < 100:
42
+ break
43
+ return sorted({r["name"]: r for r in found}.values(), key=lambda r: r["name"].lower())
44
+
45
+
46
+ def custom_properties(api, org: str) -> dict:
47
+ # Tier, lifecycle, system: GitHub's own repo metadata, read rather than rebuilt (one paged call for the whole org).
48
+ out = {}
49
+ for page in range(1, 51):
50
+ body = read(api, f"/orgs/{org}/properties/values", {"per_page": 100, "page": page})
51
+ if body is NA or not isinstance(body, list):
52
+ return out
53
+ for r in body:
54
+ out[r.get("repository_full_name")] = {p.get("property_name"): p.get("value") for p in r.get("properties") or []}
55
+ if len(body) < 100:
56
+ return out
57
+ return out
58
+
59
+
60
+ def branch_rules(api, repo: str, branch: str | None) -> list[str] | None:
61
+ # The rules that apply to the default branch, from rulesets and branch protection alike.
62
+ body = read(api, f"/repos/{repo}/rules/branches/{branch}") if branch else NA
63
+ return None if body is NA else sorted({r.get("type") for r in body if isinstance(r, dict) and r.get("type")})
64
+
65
+
66
+ def _json(api, repo: str, path: str) -> dict | None:
67
+ text = read_file(api, repo, path, DATA_BRANCH)
68
+ try:
69
+ data = json.loads(text) if text is not NA else None
70
+ except ValueError:
71
+ return None
72
+ return accept(data)
73
+
74
+
75
+ def module_facts(api, repo: str, pre: dict | None = None) -> dict | None:
76
+ """The suite's results for this repo, or None where the suite doesn't run (no data branch)."""
77
+ if (not pre["data_branch"]) if pre else read(api, f"/repos/{repo}/branches/{DATA_BRANCH}") is NA:
78
+ return None
79
+ # Since Phase 11 one file holds every fact as a number; older installs keep the three files below.
80
+ app = None
81
+ if pre and pre.get("app"):
82
+ try:
83
+ app = accept(json.loads(pre["app"]))
84
+ except ValueError:
85
+ app = None
86
+ f = (app or _json(api, repo, "docs/app.json") or {}).get("facts")
87
+ if isinstance(f, dict):
88
+ share, score = f.get("ci_first_try"), f.get("community_score")
89
+ return {"ci_first_try": f"{share:.0%}" if isinstance(share, (int, float)) else None, "ci_first_try_share": share,
90
+ "flow": {"open": f.get("open_prs"), "stale": f.get("stale_prs"),
91
+ "first_review_median_s": f.get("first_review_median_s")} if f.get("open_prs") is not None else None,
92
+ "community_score": f"{score}/100" if isinstance(score, (int, float)) else None,
93
+ # Every number the repo publishes, for the org view.
94
+ "facts": {k: v for k, v in f.items() if isinstance(v, (int, float)) and not isinstance(v, bool)}}
95
+ ci, flow, community = _json(api, repo, "docs/badge.json"), _json(api, repo, "docs/flow.json"), \
96
+ _json(api, repo, "docs/community-score.json")
97
+ pickup = ((flow or {}).get("stages") or {}).get("pickup") or {}
98
+ return {"ci_first_try": (ci or {}).get("message"),
99
+ "flow": {"open": flow.get("open"), "stale": flow.get("stale"), "first_review_median_s": pickup.get("median_s")}
100
+ if flow else None,
101
+ "community_score": (community or {}).get("message")}
102
+
103
+
104
+ def repo_files(api, repo: dict, pre: dict | None = None) -> dict:
105
+ """README, licence, contributing guide and code of conduct: True, False, or None when nobody could check."""
106
+ if pre:
107
+ return pre["files"]
108
+ body = read(api, f"/repos/{repo['name']}/community/profile")
109
+ if body is not NA and isinstance(body, dict) and isinstance(body.get("files"), dict):
110
+ files = body["files"]
111
+ owner = repo["name"].split("/")[0].lower()
112
+ # GitHub fills gaps from the org's .github repo; the evidence says so.
113
+ defaults = [k for k in FILES if isinstance(files.get(k), dict)
114
+ and f"/{owner}/.github/" in (files[k].get("html_url") or "").lower()]
115
+ return {**{k: bool(files.get(k)) or (k == "license" and bool(repo.get("license"))) for k in FILES}, "org_defaults": defaults}
116
+ # Private repos may refuse the profile: the README is asked for directly and the licence comes from the repo itself.
117
+ return {"readme": read(api, f"/repos/{repo['name']}/readme") is not NA, "license": bool(repo.get("license")),
118
+ "contributing": None, "code_of_conduct": None}
119
+
120
+
121
+ def descriptor_for(api, repo: dict, pre: dict | None = None) -> dict:
122
+ """The repo's catalog-info.yaml as typed entities and their problems (one request), or present: False."""
123
+ if pre:
124
+ text = pre["descriptor"] if pre["descriptor"] is not None else NA
125
+ else:
126
+ text = read_file(api, repo["name"], descriptor.FILE, repo.get("default_branch"))
127
+ if text is NA:
128
+ return {"present": False}
129
+ entities, problems = descriptor.parse(text)
130
+ return {"present": True, "entities": entities, "problems": problems}
131
+
132
+
133
+ def file_facts(api, repo: dict, rules: list[dict], pre: dict | None = None) -> dict:
134
+ """For portal.toml's file rules: whether each file is there and whether its patterns match (one request per file).
135
+ Only these yes/no answers are kept, never the file's text."""
136
+ out = {}
137
+ for path in dict.fromkeys(r["path"] for r in rules):
138
+ if pre and path in pre["rule_files"]:
139
+ text = pre["rule_files"][path] if pre["rule_files"][path] is not None else NA
140
+ else:
141
+ text = read_file(api, repo["name"], path, repo.get("default_branch"))
142
+ if text is NA:
143
+ out[path] = {"present": False}
144
+ continue
145
+ found = {"present": True}
146
+ for r in rules:
147
+ for key in ("contains", "lacks"):
148
+ if r["path"] == path and r.get(key):
149
+ found[f"{key}:{r[key]}"] = bool(re.search(r[key], text[:descriptor.MAX_BYTES], re.M))
150
+ out[path] = found
151
+ return out
152
+
153
+
154
+ def scorecard_checks(client: httpx.Client, name: str, previous: dict | None, now: datetime) -> dict | None:
155
+ """OpenSSF Scorecard single checks for a public repo, from its public API (results change weekly, so read once a week)."""
156
+ stamp = now.strftime("%Y-%m-%dT%H:%M:%SZ")
157
+ if previous and (previous.get("fetched") or "") >= (now - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ"):
158
+ return previous
159
+ try:
160
+ # The API matches names case-sensitively and lists them in lower case.
161
+ resp = client.get(SCORECARD_API + name.lower(), timeout=20)
162
+ except httpx.HTTPError:
163
+ return previous
164
+ if resp.status_code == 404:
165
+ return {"fetched": stamp, "date": None, "checks": None}
166
+ if resp.status_code != 200:
167
+ return previous
168
+ try:
169
+ body = resp.json()
170
+ except ValueError:
171
+ return previous
172
+ return {"fetched": stamp, "date": body.get("date"),
173
+ "checks": {c["name"]: c.get("score") for c in body.get("checks") or [] if isinstance(c, dict) and c.get("name")}}
174
+
175
+
176
+ def push_ci(api, repo: str, now: datetime, days: int = 30, previous: dict | None = None) -> dict | None:
177
+ """First-try green and time to green for the newest 50 push runs, measured exactly like the crawl's CI benchmark,
178
+ for repos where CI Health doesn't run (one request; free when no run happened since the last check, which then
179
+ keeps the last check's numbers)."""
180
+ from mergescout.bench.ci import PAGE, repo_stats
181
+ path, params = f"/repos/{repo}/actions/runs", {"event": "push", "per_page": PAGE, "exclude_pull_requests": "true"}
182
+ tag = None
183
+ if hasattr(api, "get_if_changed"):
184
+ try:
185
+ body, tag = api.get_if_changed(path, params, (previous or {}).get("etag"))
186
+ except APIError as exc:
187
+ if not any(code in str(exc) for code in REFUSED):
188
+ raise
189
+ body = NA
190
+ if body is None:
191
+ return previous
192
+ else:
193
+ body = read(api, path, params)
194
+ runs = (body or {}).get("workflow_runs") if body is not NA and isinstance(body, dict) else None
195
+ if not runs:
196
+ return None
197
+ stats = repo_stats(runs, now - timedelta(days=days), page_full=len(runs) >= PAGE)
198
+ return {**{k: stats[k] for k in ("first_try_share", "pushes", "median_s")}, **({"etag": tag} if tag else {})} if stats else None
199
+
200
+
201
+ REVIEW_QUERY = """
202
+ query($owner:String!,$name:String!) {
203
+ rateLimit { cost remaining resetAt }
204
+ repository(owner:$owner,name:$name) {
205
+ pullRequests(last:50, orderBy:{field:CREATED_AT, direction:ASC}) {
206
+ nodes { createdAt author { __typename login } authorAssociation
207
+ reviews(first:10) { nodes { submittedAt author { login } authorAssociation } } }
208
+ }
209
+ }
210
+ }"""
211
+ MAINTAINERS = INSIDER_ASSOC
212
+
213
+
214
+ def _review_records(nodes: list[dict]) -> list[dict]:
215
+ # The newest PRs as the shared role rule reads them (author, label, reviews); no merge events in this query.
216
+ out = []
217
+ for p in nodes:
218
+ author = p.get("author") or {}
219
+ out.append({"au": author.get("login"), "at": author.get("__typename"), "as": p.get("authorAssociation"),
220
+ "ev": [["review", r.get("submittedAt"), (r.get("author") or {}).get("login"), None, r.get("authorAssociation"), None]
221
+ for r in (p.get("reviews") or {}).get("nodes") or [] if r], "cm": []})
222
+ return out
223
+
224
+
225
+ def review_stats(gql, repo: str, now: datetime, days: int = 90, min_prs: int = 10) -> dict | None:
226
+ """Median time to the first review by a maintainer other than the author, for people's PRs of the last 90 days among
227
+ the newest 50 (the crawl benchmark's definition), where Team Flow doesn't run (one GraphQL point)."""
228
+ from mergescout.github.graphql import GraphQLError, RateLimited
229
+ owner, name = repo.split("/", 1)
230
+ try:
231
+ data = gql.query(REVIEW_QUERY, {"owner": owner, "name": name}).data
232
+ except (GraphQLError, RateLimited):
233
+ return None
234
+ nodes = ((data.get("repository") or {}).get("pullRequests") or {}).get("nodes") or []
235
+ since = (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
236
+ prs = [p for p in nodes if p and (p.get("createdAt") or "") >= since
237
+ and kind_of((p.get("author") or {}).get("login"), (p.get("author") or {}).get("__typename")) == "person"]
238
+ if len(prs) < min_prs:
239
+ return None
240
+ people = People(_review_records(prs))
241
+ waits, reviewers = [], {}
242
+ for p in prs:
243
+ author = (p.get("author") or {}).get("login")
244
+ for r in (p.get("reviews") or {}).get("nodes") or []:
245
+ who = (r or {}).get("author") and r["author"].get("login")
246
+ if who and who != author and people.is_insider(who):
247
+ reviewers[who] = reviewers.get(who, 0) + 1
248
+ first = min((r["submittedAt"] for r in (p.get("reviews") or {}).get("nodes") or []
249
+ if r and r.get("submittedAt") and people.is_insider((r.get("author") or {}).get("login"))
250
+ and (r.get("author") or {}).get("login") != author), default=None)
251
+ if first:
252
+ waits.append((datetime.fromisoformat(first.replace("Z", "+00:00"))
253
+ - datetime.fromisoformat(p["createdAt"].replace("Z", "+00:00"))).total_seconds())
254
+ waits.sort()
255
+ mid = len(waits) // 2
256
+ median = (waits[mid] if len(waits) % 2 else (waits[mid - 1] + waits[mid]) / 2) if waits else None
257
+ return {"first_review_median_s": median, "prs": len(prs),
258
+ "reviewers": [w for w, _ in sorted(reviewers.items(), key=lambda kv: (-kv[1], kv[0]))][:3]}
259
+
260
+
261
+ def branch_protected(api, repo: str, branch: str | None) -> bool | None:
262
+ # Classic branch protection isn't in the rules endpoint; whether it requires reviews needs an admin token.
263
+ body = read(api, f"/repos/{repo}/branches/{branch}") if branch else NA
264
+ return bool(body.get("protected")) if body is not NA and isinstance(body, dict) else None
@@ -0,0 +1,78 @@
1
+ """Who owns a repo, checked rather than just read: the CODEOWNERS file GitHub would use, GitHub's own error check, teams that
2
+ have members, and users who are still in the org. Without a file, recent committers are offered as suggestions only."""
3
+ from collections import Counter
4
+ from datetime import datetime, timedelta
5
+
6
+ from stackvitals import codeowners
7
+ from stackvitals.portal.github import NA, read, read_file, status
8
+
9
+ # Commit authors that are not people: GitHub's placeholder for unlinked emails, web edits, deleted accounts.
10
+ PLACEHOLDERS = {"invalid-email-address", "web-flow", "ghost"}
11
+ CATCH_ALL = {"*", "/*", "/", "**", "/**"}
12
+ CODEOWNERS_PATHS = codeowners.PATHS
13
+ MAX_USER_CHECKS = 10
14
+
15
+
16
+ parse_codeowners = codeowners.parse
17
+
18
+
19
+ def suggest(api, repo: str, now: datetime, days: int = 90) -> list[str]:
20
+ commits = read(api, f"/repos/{repo}/commits", {"since": (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ"),
21
+ "per_page": 100})
22
+ if commits is NA or not isinstance(commits, list):
23
+ return []
24
+ counts = Counter(((c.get("author") or {}).get("login") for c in commits))
25
+ # Machine accounts are often plain users named like bots (zaniebot, astral-docs-bot): never suggested as owners.
26
+ return [who for who, _ in counts.most_common()
27
+ if who and who.lower() not in PLACEHOLDERS and not who.lower().endswith(("bot", "[bot]", "-bot"))][:3]
28
+
29
+
30
+ def owner_status(api, org: str, repo: dict, now: datetime, cache: dict, members_visible: bool = True,
31
+ reviewers: list[str] | None = None, pre: dict | None = None) -> dict:
32
+ """valid / weak / none, with the reasons. Team sizes and memberships are cached for the whole run. In public mode
33
+ the token can't see private memberships, so users aren't checked (members_visible=False)."""
34
+ name, branch = repo["name"], repo.get("default_branch")
35
+ text = file = None
36
+ if pre:
37
+ file, text = pre["codeowners"]
38
+ for path in () if pre else CODEOWNERS_PATHS:
39
+ got = read_file(api, name, path, branch)
40
+ if got is not NA:
41
+ text, file = got, path
42
+ break
43
+ if text is None:
44
+ # People who review are better owner candidates than committers (machine accounts commit, e.g. charmcli).
45
+ return {"status": "none", "file": None, "reasons": ["no CODEOWNERS file"], "owners": [], "teams": {}, "notes": [],
46
+ "suggested": (reviewers or [])[:3] or suggest(api, name, now),
47
+ "suggested_from": "recent reviews" if reviewers else "recent commits"}
48
+ rules = parse_codeowners(text)
49
+ owners = sorted({o for _, os in rules for o in os})
50
+ reasons, teams, notes = [], {}, []
51
+ if not any(pattern in CATCH_ALL and os for pattern, os in rules):
52
+ reasons.append("no catch-all line (* or /): files outside the listed paths have no owner")
53
+ errors = read(api, f"/repos/{name}/codeowners/errors", {"ref": branch} if branch else None)
54
+ if errors is not NA and (errors or {}).get("errors"):
55
+ reasons.append(f"{len(errors['errors'])} errors in CODEOWNERS (GitHub's own check)")
56
+ users = 0
57
+ for owner in owners:
58
+ if owner.startswith("@") and "/" in owner:
59
+ team = owner[1:]
60
+ team_org, slug = team.split("/", 1)
61
+ if ("team", team) not in cache:
62
+ members = read(api, f"/orgs/{team_org}/teams/{slug}/members", {"per_page": 100})
63
+ cache[("team", team)] = NA if members is NA else len(members)
64
+ teams[team] = cache[("team", team)]
65
+ if teams[team] == 0:
66
+ reasons.append(f"team {team} has no members")
67
+ elif owner.startswith("@") and members_visible and users < MAX_USER_CHECKS:
68
+ user, users = owner[1:], users + 1
69
+ if ("member", user) not in cache:
70
+ cache[("member", user)] = status(api, f"/orgs/{org}/members/{user}")
71
+ if cache[("member", user)] == 404:
72
+ reasons.append(f"{user} is named as an owner but is not a member of {org}")
73
+ if any(v is NA for v in teams.values()):
74
+ notes.append("team members not visible to this token")
75
+ if not members_visible and any(o.startswith("@") and "/" not in o for o in owners):
76
+ notes.append("org membership of named users not checked (not visible to this token)")
77
+ return {"status": "weak" if reasons else "valid", "file": file, "reasons": reasons, "owners": owners, "notes": notes,
78
+ "teams": {k: (None if v is NA else v) for k, v in teams.items()}}
@@ -0,0 +1,153 @@
1
+ """Markdown pages in the portal repo, which GitHub shows only to people who can read that repo. Private repos never reach
2
+ a page anyone else can open."""
3
+ from stackvitals.flow.report import _md
4
+
5
+
6
+ def publishable(repos: list[dict], destination_private: bool) -> list[dict]:
7
+ # The guard: a public destination gets public repos only, whatever the token could read.
8
+ return list(repos) if destination_private else [r for r in repos if not r.get("private")]
9
+
10
+
11
+ def _cell(text) -> str:
12
+ return _md(" ".join(str(text).split())).replace("@", "@​")
13
+
14
+
15
+ def _owners(r: dict) -> tuple[str, str]:
16
+ o = r.get("owners") or {}
17
+ why = "; ".join((o.get("reasons") or []) + (o.get("notes") or []))
18
+ if o.get("suggested"):
19
+ why += ("; " if why else "") + f"suggested from {o.get('suggested_from') or 'recent commits'}: " + ", ".join(o["suggested"])
20
+ return o.get("status") or "-", why
21
+
22
+
23
+ CATALOG_ROWS = 200
24
+
25
+
26
+ def catalog_entities(active: list[dict]) -> tuple[list[dict], list[tuple[str, str]], int]:
27
+ """Every entity the active repos' catalog-info.yaml files declare (with its repo), their problems, and how many
28
+ repos have a file."""
29
+ entities, problems, files = [], [], 0
30
+ for r in active:
31
+ d = r.get("descriptor") or {}
32
+ if d.get("present"):
33
+ files += 1
34
+ entities += [{**e, "repo": r["name"]} for e in d.get("entities") or []]
35
+ problems += [(r["name"], p) for p in d.get("problems") or []]
36
+ return entities, problems, files
37
+
38
+
39
+ def _owner_cell(e: dict) -> str:
40
+ check = e.get("owner_check") or []
41
+ note = {"pass": "", "fail": " (not in CODEOWNERS)"}.get(check[0] if check else "", "")
42
+ return f"{e.get('owner') or '-'}{note}"
43
+
44
+
45
+ def _catalog_lines(active: list[dict]) -> list[str]:
46
+ entities, problems, files = catalog_entities(active)
47
+ if not files:
48
+ return []
49
+ off = sum(1 for e in entities if (e.get("owner_check") or [""])[0] == "fail")
50
+ out = ["## Catalog", "", f"{len(entities)} entities from {files} of {len(active)} active repos' catalog-info.yaml files"
51
+ + (f"; {off} owner{'' if off == 1 else 's'} not in CODEOWNERS" if off else "") + ".", "",
52
+ "| Entity | Kind | Type | Lifecycle | Owner | System | Repo |", "|---|---|---|---|---|---|---|"]
53
+ for e in entities[:CATALOG_ROWS]:
54
+ out.append(f"| {_cell(e['name'])} | {e['kind']} | {_cell(e.get('type') or '-')} | {_cell(e.get('lifecycle') or '-')} | "
55
+ f"{_cell(_owner_cell(e))} | {_cell(e.get('system') or '-')} | {_cell(e['repo'])} |")
56
+ if problems:
57
+ out += ["", "Problems: " + "; ".join(f"{_cell(repo)}: {_cell(p)}" for repo, p in problems[:20]) + "."]
58
+ return out + [""]
59
+
60
+
61
+ def render_catalog(catalog: dict, destination_private: bool) -> str:
62
+ """The catalog page: owner status counts, then one row per active repo with the evidence."""
63
+ repos = publishable(catalog["repos"], destination_private)
64
+ active = [r for r in repos if not r.get("archived") and not r.get("fork")]
65
+ counts = {s: sum(1 for r in active if (r.get("owners") or {}).get("status") == s) for s in ("valid", "weak", "none")}
66
+ archived, forks = sum(1 for r in repos if r.get("archived")), sum(1 for r in repos if r.get("fork") and not r.get("archived"))
67
+ out = [f"# Portal: {_cell(catalog['org'])}", "",
68
+ f"{len(repos)} repos ({len(active)} active, {archived} archived, {forks} forks), updated {catalog['built_at'][:16]} UTC.",
69
+ "", f"Owners: {counts['valid']} valid, {counts['weak']} weak, {counts['none']} without a CODEOWNERS file.", ""]
70
+ if len(repos) < len(catalog["repos"]):
71
+ out += ["Private repos are left out of this page because the portal repo is public.", ""]
72
+ if not catalog.get("complete", True):
73
+ out += ["Some repos keep an earlier check (GitHub's hourly limit); the next run continues with them.", ""]
74
+ scored = [r for r in active if r.get("score")]
75
+ if scored:
76
+ n = {lvl: sum(1 for r in scored if r["score"]["level"] == lvl) for lvl in ("gold", "silver", "bronze", None)}
77
+ out += [f"Levels: {n['gold']} gold, {n['silver']} silver, {n['bronze']} bronze, {n[None]} below bronze.", ""]
78
+ failed = {}
79
+ for r in scored:
80
+ for res in r["score"]["results"]:
81
+ if res["result"] == "fail":
82
+ failed[res["id"]] = failed.get(res["id"], 0) + 1
83
+ if failed:
84
+ top = sorted(failed.items(), key=lambda kv: (-kv[1], kv[0]))[:5]
85
+ out += ["Most failed rules: " + ", ".join(f"{_cell(k)} ({v} repo{'' if v == 1 else 's'})" for k, v in top) + ".", ""]
86
+ for c in catalog.get("campaigns") or []:
87
+ left = f"; {c['days_left']} days left (until {c['until']})" if c.get("days_left") is not None else ""
88
+ out += [f"## Campaign: {_cell(c['title'])}", "",
89
+ f"{c['passing']} of {c['targets']} repos pass ({', '.join(_cell(r) for r in c['rules'])}){left}."]
90
+ if c["failing"]:
91
+ out.append("Still failing: " + ", ".join(_cell(n) for n in c["failing"][:30])
92
+ + (f" and {len(c['failing']) - 30} more" if len(c["failing"]) > 30 else "") + ".")
93
+ if len(c["burn_down"]) > 1:
94
+ out.append("Failing over time: " + " → ".join(f"{d} {n}" for d, n in c["burn_down"][-8:]) + ".")
95
+ out.append("")
96
+ out += _catalog_lines(active)
97
+ out += ["## Repos", "", "| Repo | Level | Owners | Why | Language | Last push | CI first try | Community | Branch rules |",
98
+ "|---|---|---|---|---|---|---|---|---|"]
99
+ for r in active:
100
+ status, why = _owners(r)
101
+ m = r.get("modules") or {}
102
+ rules = r.get("rules")
103
+ level = ((r.get("score") or {}).get("level") or "below bronze") if r.get("score") else "-"
104
+ when = r.get("since") or {}
105
+ if when.get("owners") and status != "-":
106
+ status = f"{status} since {when['owners']}"
107
+ if when.get("level") and r.get("score"):
108
+ level = f"{level} since {when['level']}"
109
+ out.append(f"| {_cell(r['name'])} | {level} | {status} | {_cell(why) or '-'} | {_cell(r.get('language') or '-')} | "
110
+ f"{(r.get('pushed') or '-')[:10]} | {_cell(m.get('ci_first_try') or '-')} | {_cell(m.get('community_score') or '-')} | "
111
+ f"{len(rules) if isinstance(rules, list) else 'not available'} |")
112
+ return "\n".join(out) + "\n"
113
+
114
+
115
+ def render_catalog_html(catalog: dict, destination_private: bool, pages=()) -> str:
116
+ """The same catalog as a page in the shared header (docs/portal.html), under the same guard as the Markdown."""
117
+ import html
118
+
119
+ from stackvitals import shell
120
+ e = html.escape
121
+ repos = publishable(catalog["repos"], destination_private)
122
+ active = [r for r in repos if not r.get("archived") and not r.get("fork")]
123
+ counts = {s: sum(1 for r in active if (r.get("owners") or {}).get("status") == s) for s in ("valid", "weak", "none")}
124
+ body = [f"<p>{len(repos)} repos ({len(active)} active). Owners: {counts['valid']} valid, {counts['weak']} weak, "
125
+ f"{counts['none']} without a CODEOWNERS file.</p>"]
126
+ if len(repos) < len(catalog["repos"]):
127
+ body.append("<p class=mut>Private repos are left out of this page because the portal repo is public.</p>")
128
+ for c in catalog.get("campaigns") or []:
129
+ body.append(f"<h2>Campaign: {e(c['title'])}</h2><p>{c['passing']} of {c['targets']} repos pass "
130
+ f"({e(', '.join(c['rules']))}).</p>")
131
+ entities, problems, files = catalog_entities(active)
132
+ if files:
133
+ body.append(f"<h2>Catalog</h2><p>{len(entities)} entities from {files} of {len(active)} active repos' catalog-info.yaml "
134
+ "files.</p><div class=tbl><table class=find><tr><th>Entity</th><th data-filter>Kind</th><th data-filter>Type</th>"
135
+ "<th data-filter>Lifecycle</th><th>Owner</th><th data-filter>System</th><th>Repo</th></tr>")
136
+ body += [f"<tr><td>{e(x['name'])}</td><td>{e(x['kind'])}</td><td>{e(x.get('type') or '-')}</td>"
137
+ f"<td>{e(x.get('lifecycle') or '-')}</td><td>{e(_owner_cell(x))}</td><td>{e(x.get('system') or '-')}</td>"
138
+ f"<td>{e(x['repo'])}</td></tr>" for x in entities[:CATALOG_ROWS]]
139
+ body.append("</table></div>")
140
+ if problems:
141
+ body.append("<p class=mut>Problems: " + "; ".join(f"{e(repo)}: {e(p)}" for repo, p in problems[:20]) + ".</p>")
142
+ body.append("<h2>Repos</h2><div class=tbl><table class=find><tr><th data-filter>Level</th><th>Repo</th>"
143
+ "<th data-filter>Owners</th><th>Why</th><th data-filter>Language</th><th>Last push</th><th>CI first try</th>"
144
+ "<th>Community</th></tr>")
145
+ for r in active:
146
+ status, why = _owners(r)
147
+ m = r.get("modules") or {}
148
+ level = ((r.get("score") or {}).get("level") or "below bronze") if r.get("score") else "-"
149
+ body.append(f"<tr><td>{e(level)}</td><td>{e(str(r['name']))}</td><td>{e(status)}</td><td class=mut>{e(why) or '-'}</td>"
150
+ f"<td>{e(str(r.get('language') or '-'))}</td><td>{e((r.get('pushed') or '-')[:10])}</td>"
151
+ f"<td>{e(str(m.get('ci_first_try') or '-'))}</td><td>{e(str(m.get('community_score') or '-'))}</td></tr>")
152
+ body.append("</table></div>")
153
+ return shell.page("Portal", catalog["org"], "portal.html", f"Updated {e(catalog['built_at'][:16])} UTC.", "".join(body), pages)
@@ -0,0 +1,32 @@
1
+ """The org view: every repo that runs the suite, rolled up from the facts each publishes on its own data branch (counts
2
+ summed, rates and times as medians across repos, and the repos that lead each), so a platform team sees the org's
3
+ CI, review and delivery health on one page without reading any repo's raw history."""
4
+ from statistics import median
5
+
6
+ SUMS = ("ci_flaky", "ci_broken", "open_prs", "stale_prs", "deployments", "findings", "newcomers_waiting", "vulns_open",
7
+ "vulns_serious")
8
+ MEDIANS = ("ci_first_try", "pickup_p75_h", "review_p75_h", "cycle_p75_h", "change_failure_rate", "community_score")
9
+ # Which way is worse for each number the leaders are ranked by.
10
+ WORSE_HIGH = {"ci_flaky", "ci_broken", "stale_prs", "findings", "newcomers_waiting", "pickup_p75_h", "review_p75_h",
11
+ "cycle_p75_h", "change_failure_rate", "vulns_serious"}
12
+ LEADERS = 5
13
+
14
+
15
+ def _num(v) -> bool:
16
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
17
+
18
+
19
+ def org_rollup(repos: list[dict]) -> dict:
20
+ live = [r for r in repos if not r.get("archived") and not r.get("fork")]
21
+ facts = {r["name"]: ((r.get("modules") or {}).get("facts") or {}) for r in live if (r.get("modules") or {}).get("facts")}
22
+ out = {"repos": len(live), "running": len(facts), "totals": {}, "medians": {}, "counts": {}, "leaders": {}}
23
+ for key in SUMS:
24
+ vals = [f[key] for f in facts.values() if _num(f.get(key))]
25
+ out["totals"][key] = sum(vals) if vals else None
26
+ for key in MEDIANS:
27
+ vals = [f[key] for f in facts.values() if _num(f.get(key))]
28
+ out["medians"][key], out["counts"][key] = (round(median(vals), 4) if vals else None), len(vals)
29
+ for key in WORSE_HIGH:
30
+ ranked = sorted(((n, f[key]) for n, f in facts.items() if _num(f.get(key)) and f[key] > 0), key=lambda x: -x[1])
31
+ out["leaders"][key] = [{"repo": n, "value": v} for n, v in ranked[:LEADERS]]
32
+ return out