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
File without changes
@@ -0,0 +1,205 @@
1
+ import json
2
+ from datetime import datetime, timezone
3
+ from pathlib import Path
4
+
5
+ from stackvitals.ci.action import BRANCH, DataBranch, GitError
6
+ from stackvitals.ci.api import ActionsAPI
7
+ from stackvitals.flow.action import FLOW_EVENTS
8
+ from stackvitals.flow.action import make_gql as _gql
9
+ import httpx
10
+
11
+ from stackvitals.ci import bench as ci_bench
12
+ from stackvitals.flow import peers as flow_peers
13
+ from stackvitals.portal.catalog import build_catalog
14
+ from stackvitals.ci.api import APIError, OutOfBudget
15
+ from stackvitals.portal.catalog import cacheable
16
+ from stackvitals.portal.export import entities_yaml
17
+ from stackvitals.portal.checks import CAP as CHECKS_CAP
18
+ from stackvitals.portal.checks import load_level, plan
19
+ from stackvitals.portal.golden import GoldenError, load_paths
20
+ from stackvitals.portal.rollup import org_rollup
21
+ from stackvitals.portal.checks import send as send_checks
22
+ from stackvitals.portal.campaigns import load_campaigns, progress, reminder_text, reminders_due, update_tracking_issue
23
+ from stackvitals.portal.github import NA, read, read_file
24
+ from stackvitals.portal.history import since
25
+ from stackvitals import shell
26
+ from stackvitals.portal.pages import _cell, publishable, render_catalog, render_catalog_html
27
+ from stackvitals.portal.scorecard import file_rules, rules_or_default, score_catalog
28
+ from stackvitals.core.store import accept, published
29
+
30
+ def _scorecard_client() -> httpx.Client:
31
+ return httpx.Client(timeout=20)
32
+
33
+
34
+ LABEL = "Portal"
35
+ SETUP = ("Public mode: only the org's public repos are read. To include private repos, give the Action a token from an "
36
+ "org-owned read-only GitHub App as `portal-token` (see docs/modules.md).")
37
+
38
+
39
+ def _history_row(r: dict) -> dict:
40
+ # One compact line per repo per day: enough for "since when" and campaign progress later.
41
+ o = r.get("owners") or {}
42
+ return {"name": r["name"], "archived": r.get("archived"), "language": r.get("language"), "owners": o.get("status"),
43
+ "why": len(o.get("reasons") or []),
44
+ "level": (r.get("score") or {}).get("level"),
45
+ "failed": [x["id"] for x in (r.get("score") or {}).get("results") or [] if x["result"] == "fail"],
46
+ "modules": r.get("modules"), "rules": r.get("rules"), "checked": r.get("checked")}
47
+
48
+
49
+ def portal_step(ws, env: dict, now: datetime, make_api=ActionsAPI) -> dict:
50
+ """Portal's part of a job: the catalog, a daily history line per repo and the pages into the shared checkout;
51
+ after the commit, the campaign tracking issues and the summary."""
52
+ repo, builtin = ws.repo, env.get("STACKVITALS_TOKEN") or env.get("GITHUB_TOKEN")
53
+ app = (env.get("STACKVITALS_PORTAL_TOKEN") or "").strip()
54
+ org = (env.get("STACKVITALS_PORTAL_ORG") or "").strip() or repo.split("/")[0]
55
+ mode = "private" if app else "public"
56
+ own = make_api(builtin, repo, max_requests=5)
57
+ here = read(own, f"/repos/{repo}")
58
+ # The org's own rules live in this repo as portal.toml; without it the default pack applies.
59
+ text = read_file(own, repo, "portal.toml")
60
+ rules, exemptions, rule_notes = rules_or_default(None if text is NA else text)
61
+ try:
62
+ campaigns = load_campaigns(None if text is NA else text)
63
+ except ValueError:
64
+ campaigns = [] # a broken file was already reported by the rules note
65
+ # Unknown visibility counts as public: the safe side.
66
+ destination_private = bool(here.get("private")) if here is not NA and isinstance(here, dict) else False
67
+ folder = ws.path / "portal"
68
+ folder.mkdir(parents=True, exist_ok=True)
69
+ try:
70
+ previous = accept(json.loads((folder / "catalog.json").read_text(encoding="utf-8")))
71
+ except (OSError, ValueError):
72
+ previous = None
73
+ api = make_api(app or builtin, repo, max_requests=4000 if app else 700)
74
+ # Conditional reads kept between runs: a repo whose rules and owners didn't change costs nothing the next day.
75
+ try:
76
+ api.etags = json.loads((folder / "etags.json").read_text(encoding="utf-8"))
77
+ except (OSError, ValueError):
78
+ api.etags = {}
79
+ api.cacheable = cacheable
80
+ catalog = build_catalog(api, org, mode, now, previous=previous, scorecard_client=_scorecard_client(),
81
+ gql=_gql(app or builtin), file_rules=file_rules(rules))
82
+ # Peer levels need the public benchmark files (none before launch: those rules are "not available").
83
+ bench = {"ci": ci_bench.load(None, ws.work / "bench"), "flow": flow_peers.load(None, ws.work / "bench")}
84
+ score_catalog(catalog, rules, exemptions, now, bench)
85
+ # Since when each repo has had its owner status and level, from the days already kept.
86
+ when = since(folder / "history", catalog["repos"], now.strftime("%Y-%m-%d"))
87
+ for r in catalog["repos"]:
88
+ if r.get("score"):
89
+ r["since"] = when.get(r["name"])
90
+ kept = publishable(catalog["repos"], destination_private)
91
+ # The org view, from the facts the repos this page may show publish themselves.
92
+ catalog["org_view"] = org_rollup(kept)
93
+ # Campaign progress only ever counts repos this page may show.
94
+ catalog["campaigns"] = [progress(c, kept, folder / "history", now) for c in campaigns]
95
+ # The golden paths people may run from the app (what each is, never the App's token).
96
+ try:
97
+ golden = load_paths(None if text is NA else text)
98
+ except (GoldenError, ValueError) as exc:
99
+ golden, rule_notes = {}, [*rule_notes, f"Golden paths in portal.toml: {exc}"]
100
+ catalog["golden"] = [{k: g[k] for k in ("id", "title", "kind", "description", "private")} for g in golden.values()]
101
+ catalog["golden_ready"] = bool(app)
102
+ (folder / "catalog.json").write_text(json.dumps(published({**catalog, "repos": kept}), indent=1), encoding="utf-8")
103
+ # Kept answers name repos and hold their rules: never on a public portal repo that reads private repos, and only
104
+ # for repos still listed.
105
+ if isinstance(getattr(api, "etags", None), dict) and (destination_private or mode == "public"):
106
+ live = tuple(f"/repos/{r['name']}/" for r in catalog["repos"])
107
+ kept_tags = {k: v for k, v in api.etags.items() if k.startswith(live) or k.startswith(f"/orgs/{org}/")}
108
+ (folder / "etags.json").write_text(json.dumps(kept_tags, separators=(",", ":")), encoding="utf-8")
109
+ else:
110
+ (folder / "etags.json").unlink(missing_ok=True)
111
+ (folder / "README.md").write_text(render_catalog(catalog, destination_private), encoding="utf-8")
112
+ # Backstage: one location file for the repos without their own catalog-info.yaml.
113
+ pages = here.get("has_pages") and not destination_private if isinstance(here, dict) else False
114
+ owner, name = repo.split("/")
115
+ (folder / "entities.yaml").write_text(entities_yaml(kept, f"https://{owner.lower()}.github.io/{name}/" if pages else None),
116
+ encoding="utf-8")
117
+ docs = ws.path / "docs"
118
+ docs.mkdir(parents=True, exist_ok=True)
119
+ (docs / "portal.html").write_text(render_catalog_html(catalog, destination_private, shell.present(docs) | {"portal.html"}),
120
+ encoding="utf-8")
121
+ (folder / "history").mkdir(exist_ok=True)
122
+ (folder / "history" / f"{now:%Y-%m-%d}.json").write_text(
123
+ json.dumps({"built_at": catalog["built_at"], "repos": [_history_row(r) for r in kept]}, separators=(",", ":")),
124
+ encoding="utf-8")
125
+ left_out = len(catalog["repos"]) - len(kept)
126
+ out = {"mode": mode, "repos": len(kept), "complete": catalog["complete"], "left_out": left_out}
127
+ # Deadline reminders, once each: remembered before the commit so an overlapping run never sends one twice.
128
+ sent_file = folder / "reminders.json"
129
+ try:
130
+ sent = json.loads(sent_file.read_text(encoding="utf-8"))
131
+ except (OSError, ValueError):
132
+ sent = {}
133
+ remind = reminders_due(catalog.get("campaigns") or [], sent, now)
134
+ if remind:
135
+ for p, days in remind:
136
+ sent.setdefault(p["id"], []).append(days)
137
+ sent_file.write_text(json.dumps(sent, indent=1, sort_keys=True), encoding="utf-8")
138
+
139
+ # Opt-in required check: only with the org App (it writes to every repo) and a level in portal.toml.
140
+ level = load_level(None if text is NA else text)
141
+ checks = plan(catalog, level) if level and app else []
142
+ # The cap would always stop at the same repo: each run starts where the last one stopped.
143
+ cursor_file = folder / "checks-cursor.json"
144
+ try:
145
+ start = int(json.loads(cursor_file.read_text(encoding="utf-8")).get("next", 0)) % max(len(checks), 1)
146
+ except (OSError, ValueError, TypeError):
147
+ start = 0
148
+ checks = checks[start:] + checks[:start]
149
+ if checks:
150
+ # Saved before the commit (sends come after it): the next run starts about where this one's cap will end.
151
+ cursor_file.write_text(json.dumps({"next": (start + CHECKS_CAP // 3) % len(checks)}), encoding="utf-8")
152
+
153
+ def after_commit():
154
+ issue_notes = []
155
+ if level and not app:
156
+ issue_notes.append(f"[checks] level = \"{level}\" needs the org App (with Checks: write) to post checks.")
157
+ if checks:
158
+ done = send_checks(make_api(app, repo, max_requests=1000), checks)
159
+ issue_notes.append(f"Portal checks: {done['posted']} posted for {level}"
160
+ + ("; stopped at the cap, the next run carries on" if done["stopped"] else "")
161
+ + (f"; refused for {len(done['refused'])} repos (the App needs Checks: write)" if done["refused"] else "")
162
+ + ".")
163
+ for p in catalog.get("campaigns") or []:
164
+ try:
165
+ number = update_tracking_issue(make_api(builtin, repo, max_requests=10), repo, p)
166
+ for q, days in remind:
167
+ if q["id"] == p["id"] and number:
168
+ make_api(builtin, repo, max_requests=3).send("POST", f"/repos/{repo}/issues/{number}/comments",
169
+ {"body": reminder_text(p, days)})
170
+ except (APIError, OutOfBudget):
171
+ issue_notes = ["Add `issues: write` to this workflow's permissions to keep one tracking issue per campaign."]
172
+ summary = env.get("GITHUB_STEP_SUMMARY")
173
+ if summary:
174
+ active = [r for r in kept if not r.get("archived") and not r.get("fork")]
175
+ counts = {s: sum(1 for r in active if (r.get("owners") or {}).get("status") == s) for s in ("valid", "weak", "none")}
176
+ notes = [f"### StackVitals Portal: {_cell(org)}",
177
+ f"{len(kept)} repos; owners {counts['valid']} valid, {counts['weak']} weak, {counts['none']} without CODEOWNERS. "
178
+ f"The catalog is in this repo's `{BRANCH}` branch, `portal/README.md`."]
179
+ if mode == "public":
180
+ notes.append(SETUP)
181
+ notes += rule_notes + issue_notes
182
+ if left_out:
183
+ notes.append(f"This portal repo is public, so its private repos were left out ({left_out}).")
184
+ if not catalog["complete"]:
185
+ notes.append("Some repos keep an earlier check (GitHub's hourly limit); the next run continues with them.")
186
+ with open(summary, "a", encoding="utf-8") as f:
187
+ f.write("\n\n".join(notes) + "\n")
188
+ ws.after_commit(after_commit, LABEL)
189
+ return out
190
+
191
+
192
+ def run_portal_action(env: dict, now: datetime | None = None, make_api=ActionsAPI, workspace=None) -> dict | None:
193
+ """One Portal run in the org's portal repo: the catalog, a daily history line per repo and the pages, kept in this
194
+ repo's data branch. A public portal repo never receives private-repo data."""
195
+ if env.get("GITHUB_EVENT_NAME") not in FLOW_EVENTS:
196
+ return None
197
+ from stackvitals.core.workspace import run_job
198
+ now = now or datetime.now(timezone.utc)
199
+
200
+ def step(ws):
201
+ return portal_step(ws, env, now, make_api)
202
+ if workspace is not None:
203
+ return step(workspace)
204
+ job = run_job(env, [(LABEL, step)], now, message=f"stackvitals: portal {now:%Y-%m-%d %H:%M} UTC", catch=False)
205
+ return {**job["results"][LABEL], "pushed": job["outcome"] == "pushed"}
@@ -0,0 +1,80 @@
1
+ """The light core: one GraphQL query reads ten repos' files at once (CODEOWNERS, catalog-info.yaml, the rule files, the
2
+ root and docs/ listings, the community files GitHub resolves, the suite's facts on the data branch), instead of a REST
3
+ request per file per repo. The readers in inventory.py and owners.py take what this found and ask REST only for the rest."""
4
+ import json
5
+
6
+ from mergescout.github.graphql import GraphQLError, RateLimited
7
+
8
+ BATCH = 10
9
+ # How a repo runs, from its root folder's names alone (no extra request).
10
+ RUNTIMES = (("kubernetes", ("k8s", "kubernetes", "charts", "helm", "chart.yaml", "kustomization.yaml", "skaffold.yaml")),
11
+ ("container", ("dockerfile", "containerfile", "compose.yaml", "docker-compose.yml", "docker-compose.yaml")),
12
+ ("terraform", ("terraform", "main.tf", "infra")), ("serverless", ("serverless.yml", "serverless.yaml", "template.yaml")))
13
+ CODEOWNERS_PATHS = (".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS")
14
+ DATA = "stackvitals-data"
15
+
16
+
17
+ def _blob(alias: str, expression: str) -> str:
18
+ return f"{alias}: object(expression: {json.dumps(expression)}) {{ ... on Blob {{ text byteSize }} }}"
19
+
20
+
21
+ def _repo_fields(paths: list[str]) -> str:
22
+ files = [_blob(f"co{i}", f"HEAD:{p}") for i, p in enumerate(CODEOWNERS_PATHS)]
23
+ files += [_blob(f"f{i}", f"HEAD:{p}") for i, p in enumerate(paths)]
24
+ files.append(_blob("desc", "HEAD:catalog-info.yaml"))
25
+ files.append(_blob("app", f"{DATA}:docs/app.json"))
26
+ trees = " ".join(f'{a}: object(expression: "HEAD:{p}") {{ ... on Tree {{ entries {{ name type }} }} }}'
27
+ for a, p in (("root", ""), ("gh", ".github"), ("docs", "docs")))
28
+ return (" ".join(files) + " " + trees + f' data: ref(qualifiedName: "refs/heads/{DATA}") {{ name }} '
29
+ "licenseInfo { spdxId } codeOfConduct { url } contributingGuidelines { url }")
30
+
31
+
32
+ def query(names: list[str], paths: list[str]) -> str:
33
+ fields = _repo_fields(paths)
34
+ parts = [f"r{i}: repository(owner: {json.dumps(n.split('/')[0])}, name: {json.dumps(n.split('/')[1])}) {{ {fields} }}"
35
+ for i, n in enumerate(names)]
36
+ return "query { rateLimit { cost remaining resetAt } " + " ".join(parts) + " }"
37
+
38
+
39
+ def _text(node) -> str | None:
40
+ return (node or {}).get("text") if isinstance(node, dict) else None
41
+
42
+
43
+ def _names(node) -> list[str]:
44
+ return [e["name"] for e in (node or {}).get("entries") or [] if isinstance(e, dict) and e.get("name")]
45
+
46
+
47
+ def facts(node: dict, owner: str, paths: list[str]) -> dict:
48
+ """What one repo's answer says, in the shape the readers use."""
49
+ codeowners = next(((p, _text(node.get(f"co{i}"))) for i, p in enumerate(CODEOWNERS_PATHS) if _text(node.get(f"co{i}")) is not None),
50
+ (None, None))
51
+ root, gh, docs = _names(node.get("root")), _names(node.get("gh")), _names(node.get("docs"))
52
+ starts = lambda names, word: any(n.lower().startswith(word) for n in names) # noqa: E731
53
+ org_default = lambda field: f"/{owner.lower()}/.github/" in (((node.get(field) or {}).get("url")) or "").lower() # noqa: E731
54
+ files = {"readme": starts(root, "readme") or starts(gh, "readme") or starts(docs, "readme"),
55
+ "license": bool((node.get("licenseInfo") or {}).get("spdxId")) or starts(root, "license") or starts(root, "copying"),
56
+ "contributing": bool(node.get("contributingGuidelines")),
57
+ "code_of_conduct": bool(node.get("codeOfConduct"))}
58
+ files["org_defaults"] = [k for k, field in (("contributing", "contributingGuidelines"), ("code_of_conduct", "codeOfConduct"))
59
+ if files[k] and org_default(field)]
60
+ lower = {n.lower() for n in root}
61
+ runtime = [kind for kind, marks in RUNTIMES if lower & set(marks)]
62
+ return {"runtime": runtime, "codeowners": codeowners, "descriptor": _text(node.get("desc")), "app": _text(node.get("app")),
63
+ "data_branch": bool(node.get("data")), "files": files, "docs": docs if node.get("docs") else None,
64
+ "rule_files": {p: _text(node.get(f"f{i}")) for i, p in enumerate(paths)}}
65
+
66
+
67
+ def prefetch(gql, repos: list[dict], paths: list[str]) -> dict[str, dict]:
68
+ """Each repo's facts by name; repos a batch couldn't read are left out (their readers fall back to REST)."""
69
+ out = {}
70
+ for i in range(0, len(repos), BATCH):
71
+ batch = repos[i:i + BATCH]
72
+ try:
73
+ data = gql.query(query([r["name"] for r in batch], paths), {}).data or {}
74
+ except (GraphQLError, RateLimited):
75
+ continue
76
+ for j, r in enumerate(batch):
77
+ node = data.get(f"r{j}")
78
+ if isinstance(node, dict):
79
+ out[r["name"]] = facts(node, r["name"].split("/")[0], paths)
80
+ return out
@@ -0,0 +1,104 @@
1
+ """Campaigns: a set of rules, the repos they target and a date. Progress and the burn-down come from the daily history, and
2
+ one tracking issue in the portal repo is kept up to date (issues in each repo would need write access everywhere)."""
3
+ import fnmatch
4
+ import json
5
+ import tomllib
6
+ from datetime import date, datetime
7
+ from pathlib import Path
8
+
9
+ from stackvitals.flow.report import _md
10
+
11
+ MARKER = "<!-- stackvitals:campaign"
12
+ DONE = ("pass", "exempt")
13
+
14
+
15
+ def load_campaigns(text: str | None) -> list[dict]:
16
+ doc = tomllib.loads(text) if text else {}
17
+ out = []
18
+ for c in doc.get("campaign") or []:
19
+ if isinstance(c, dict) and c.get("id") and c.get("rules"):
20
+ out.append({"id": str(c["id"]), "title": str(c.get("title") or c["id"]), "rules": [str(r) for r in c["rules"]],
21
+ "repos": str(c.get("repos") or "*"), "language": c.get("language"), "until": str(c.get("until") or "")})
22
+ return out
23
+
24
+
25
+ def _targeted(c: dict, name: str, language: str | None) -> bool:
26
+ return fnmatch.fnmatch(name.lower(), c["repos"].lower()) and (not c["language"] or (language or "").lower() == c["language"].lower())
27
+
28
+
29
+ def progress(c: dict, repos: list[dict], history: Path, now: datetime) -> dict:
30
+ """How many targeted repos pass every campaign rule today, who still fails, and the failing count on each kept day."""
31
+ today = now.strftime("%Y-%m-%d")
32
+ targets = [r for r in repos if not r.get("archived") and not r.get("fork") and _targeted(c, r["name"], r.get("language"))]
33
+
34
+ def passes(r):
35
+ results = {x["id"]: x["result"] for x in (r.get("score") or {}).get("results") or []}
36
+ return all(results.get(rule) in DONE for rule in c["rules"])
37
+ failing = sorted(r["name"] for r in targets if not passes(r))
38
+ passed = sorted(r["name"] for r in targets if passes(r))
39
+ burn = []
40
+ for path in sorted(Path(history).glob("*.json")):
41
+ if path.stem >= today:
42
+ continue
43
+ try:
44
+ rows = json.loads(path.read_text(encoding="utf-8")).get("repos") or []
45
+ except (OSError, ValueError):
46
+ continue
47
+ burn.append([path.stem, sum(1 for row in rows if _targeted(c, row.get("name") or "", row.get("language"))
48
+ and set(c["rules"]) & set(row.get("failed") or []))])
49
+ burn.append([today, len(failing)])
50
+ try:
51
+ days_left = (date.fromisoformat(c["until"]) - now.date()).days if c["until"] else None
52
+ except ValueError:
53
+ days_left = None
54
+ return {**c, "targets": len(targets), "passing": len(targets) - len(failing), "failing": failing, "passed": passed,
55
+ "days_left": days_left, "burn_down": burn}
56
+
57
+
58
+ REMIND_AT = (7, 1)
59
+
60
+
61
+ def reminders_due(campaigns: list[dict], sent: dict, now: datetime) -> list[tuple[dict, int]]:
62
+ """Campaigns at 7 or 1 days left that still have failing repos and haven't had that reminder (Cortex's rhythm)."""
63
+ out = []
64
+ for p in campaigns:
65
+ days = p.get("days_left")
66
+ if p.get("failing") and days in REMIND_AT and days not in (sent.get(p["id"]) or []):
67
+ out.append((p, days))
68
+ return out
69
+
70
+
71
+ def reminder_text(p: dict, days: int) -> str:
72
+ names = ", ".join(_md(n) for n in p["failing"][:20]) + (f" and {len(p['failing']) - 20} more" if len(p["failing"]) > 20 else "")
73
+ return (f"Reminder: {days} day{'' if days == 1 else 's'} left for this campaign (until {p['until']}). "
74
+ f"{len(p['failing'])} repo{'' if len(p['failing']) == 1 else 's'} still fail: {names}.")
75
+
76
+
77
+ def issue_body(p: dict) -> str:
78
+ lines = [f"{MARKER} {p['id']} -->", f"### Campaign: {_md(p['title'])}", "",
79
+ f"{p['passing']} of {p['targets']} repos pass every rule ({', '.join(map(_md, p['rules']))})"
80
+ + (f"; {p['days_left']} days left (until {p['until']})." if p.get("days_left") is not None else ".")]
81
+ if p["failing"] or p.get("passed"):
82
+ # A checklist ticked again on every run: done repos checked, the rest open.
83
+ lines += ["", "**Repos:**", *[f"- [ ] {_md(n)}" for n in p["failing"][:100]],
84
+ *[f"- [x] {_md(n)}" for n in (p.get("passed") or [])[:50]]]
85
+ if len(p["failing"]) > 100:
86
+ lines.append(f"- ... and {len(p['failing']) - 100} more still failing")
87
+ if len(p["burn_down"]) > 1:
88
+ lines += ["", "**Failing repos over time:** " + " → ".join(f"{d} {n}" for d, n in p["burn_down"][-12:])]
89
+ lines += ["", "<sub>Kept up to date by StackVitals Portal on each run; edits to this text are replaced.</sub>"]
90
+ return "\n".join(lines)
91
+
92
+
93
+ def update_tracking_issue(api, repo: str, p: dict) -> int | None:
94
+ """One issue per campaign in the portal repo, found by its marker and edited in place; closed once every repo passes."""
95
+ marker = f"{MARKER} {p['id']} -->"
96
+ open_issues = api._get(f"/repos/{repo}/issues", {"state": "open", "per_page": 100})
97
+ found = next((i for i in open_issues if not i.get("pull_request") and marker in (i.get("body") or "")), None)
98
+ body = issue_body(p)
99
+ if found:
100
+ api.send("PATCH", f"/repos/{repo}/issues/{found['number']}", {"body": body, **({} if p["failing"] else {"state": "closed"})})
101
+ return found["number"]
102
+ if p["failing"]:
103
+ return api.send("POST", f"/repos/{repo}/issues", {"title": f"Campaign: {p['title']}", "body": body}).get("number")
104
+ return None
@@ -0,0 +1,104 @@
1
+ """The org catalog: every repo with its settings, owners with evidence and the suite's module results. Repos checked longest
2
+ ago go first, so a run that hits its request budget (the built-in token allows 1,000 an hour) keeps earlier results for the
3
+ rest and the next run continues."""
4
+ import threading
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from datetime import datetime
7
+
8
+ from stackvitals.ci.api import OutOfBudget
9
+ from stackvitals.portal import descriptor
10
+ from stackvitals.portal.batch import prefetch
11
+ from stackvitals.portal.inventory import (branch_protected, branch_rules, custom_properties, descriptor_for, file_facts, list_repos,
12
+ module_facts, push_ci, repo_files, review_stats, scorecard_checks)
13
+ from stackvitals.portal.owners import owner_status
14
+
15
+
16
+ WORKERS = 6
17
+ PER_REPO = 15 # REST requests one repo can take, for deciding when to slow down
18
+
19
+
20
+ def cacheable(path: str) -> bool:
21
+ """Small answers that rarely change, read conditionally: an unchanged one costs nothing from the hourly limit."""
22
+ return any(part in path for part in ("/rules/branches/", "/codeowners/errors", "/teams/", "/protection",
23
+ "/properties/values", "/branches/stackvitals-data"))
24
+
25
+
26
+ def _iso(dt: datetime) -> str:
27
+ return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
28
+
29
+
30
+ def _owner_checks(d: dict, owners: dict) -> dict:
31
+ # Each owned entity says whether CODEOWNERS agrees, for the Catalog tab (no rule needed).
32
+ if d.get("present") and owners.get("status") != "none":
33
+ for e in d["entities"]:
34
+ if e["kind"] in descriptor.NEEDS:
35
+ e["owner_check"] = list(descriptor.owner_check(e.get("owner"), owners.get("owners") or []))
36
+ return d
37
+
38
+
39
+ def build_catalog(api, org: str, mode: str, now: datetime, previous: dict | None = None, scorecard_client=None,
40
+ gql=None, file_rules: list[dict] | None = None) -> dict:
41
+ stamp = _iso(now)
42
+ old = {r["name"]: r for r in (previous or {}).get("repos") or [] if isinstance(r, dict) and r.get("name")}
43
+ repos = list_repos(api, org, mode)
44
+ props = custom_properties(api, org)
45
+ cache, out = {}, {}
46
+ order = sorted(repos, key=lambda r: (old.get(r["name"]) or {}).get("checked") or "")
47
+ # Ten repos' files in one GraphQL query; a repo it couldn't read is asked over REST as before.
48
+ paths = list(dict.fromkeys(x["path"] for x in file_rules or []))
49
+ pre = prefetch(gql, [r for r in order if not (r["archived"] or r["fork"])], paths) if gql is not None else {}
50
+ stop = threading.Event()
51
+
52
+ def check(r: dict) -> tuple[str, dict]:
53
+ kept = {**old[r["name"]], **r} if r["name"] in old else {**r, "checked": None}
54
+ # Archived repos and forks are listed, not judged: a fork carries its upstream's CODEOWNERS (astral-sh/peps: 783 errors).
55
+ # The org's .github repo holds shared settings, not a product.
56
+ if r["archived"] or r["fork"] or r["name"].split("/")[-1].lower() == ".github":
57
+ return r["name"], r
58
+ if stop.is_set():
59
+ return r["name"], kept
60
+ try:
61
+ # OpenSSF publishes Scorecard results for public repos only; private repos stay "not available" for those rules.
62
+ openssf = scorecard_checks(scorecard_client, r["name"], (old.get(r["name"]) or {}).get("openssf"), now) \
63
+ if scorecard_client is not None and not r["private"] else None
64
+ mine = pre.get(r["name"])
65
+ modules = module_facts(api, r["name"], mine)
66
+ reviews = review_stats(gql, r["name"], now) if gql is not None and not (modules or {}).get("flow") else None
67
+ rules = branch_rules(api, r["name"], r["default_branch"])
68
+ owners = owner_status(api, org, r, now, cache, members_visible=mode == "private",
69
+ reviewers=(reviews or {}).get("reviewers"), pre=mine)
70
+ return r["name"], {**r, "properties": props.get(r["name"]) or {}, "rules": rules,
71
+ "protected": branch_protected(api, r["name"], r["default_branch"])
72
+ if isinstance(rules, list) and "pull_request" not in rules else None,
73
+ "owners": owners, "descriptor": _owner_checks(descriptor_for(api, r, mine), owners),
74
+ "files": file_facts(api, r, file_rules or [], mine),
75
+ "modules": modules, "docs": repo_files(api, r, mine),
76
+ # The docs/ folder's pages, for the catalog's docs index.
77
+ "doc_pages": (mine or {}).get("docs"),
78
+ # How it runs (kubernetes, container, terraform, serverless), from its root folder.
79
+ "runtime": (mine or {}).get("runtime"),
80
+ # Where CI Health doesn't run, Portal measures push CI itself (one request).
81
+ "ci": None if (modules or {}).get("ci_first_try")
82
+ else push_ci(api, r["name"], now, previous=(old.get(r["name"]) or {}).get("ci")),
83
+ # Likewise first reviews where Team Flow doesn't run (one GraphQL point, a separate budget).
84
+ "reviews": reviews,
85
+ "openssf": openssf, "scorecard": (openssf or {}).get("checks"), "checked": stamp}
86
+ except OutOfBudget:
87
+ stop.set()
88
+ return r["name"], kept
89
+ except Exception as exc: # one repo's surprise keeps its earlier check and never stops the org's catalog
90
+ return r["name"], {**kept, "error": type(exc).__name__}
91
+ # Six repos at a time while the budget is ample (most of a repo's time is waiting on GitHub); one at a time near its
92
+ # end, so the repos checked longest ago still go first.
93
+ budget = getattr(api, "max_requests", None)
94
+ i = 0
95
+ with ThreadPoolExecutor(max_workers=WORKERS) as pool:
96
+ while i < len(order):
97
+ left = None if budget is None else budget - (api.requests - getattr(api, "unchanged", 0))
98
+ chunk = order[i:i + (1 if left is not None and left < WORKERS * PER_REPO else WORKERS)]
99
+ for name, record in pool.map(check, chunk):
100
+ out[name] = record
101
+ i += len(chunk)
102
+ complete = not stop.is_set()
103
+ return {"org": org, "mode": mode, "built_at": stamp, "complete": complete, "requests": getattr(api, "requests", None),
104
+ "repos": sorted(out.values(), key=lambda r: r["name"].lower())}
@@ -0,0 +1,63 @@
1
+ """Scorecards as a required check (opt-in): with `[checks] level = "silver"` in portal.toml and an org App that may write
2
+ checks, each repo's default branch and open pull requests get a "StackVitals portal" check that passes when the repo
3
+ meets the level, and says which rules keep it below. Branch protection can then require it. Written once a run, after
4
+ the catalog is saved; the result follows the daily catalog, so a fix shows on the next run."""
5
+ import tomllib
6
+
7
+ from stackvitals.ci.api import APIError, OutOfBudget
8
+ from stackvitals.portal.scorecard import LEVELS
9
+
10
+ NAME = "StackVitals portal"
11
+ CAP = 300 # check runs a run writes at most; the next run carries on with the rest
12
+ PRS = 20 # open pull requests a repo gets the check on
13
+
14
+
15
+ def load_level(text: str | None) -> str | None:
16
+ try:
17
+ level = ((tomllib.loads(text or "").get("checks") or {}).get("level") or "").lower()
18
+ except tomllib.TOMLDecodeError:
19
+ return None
20
+ return level if level in LEVELS else None
21
+
22
+
23
+ def plan(catalog: dict, level: str) -> list[dict]:
24
+ """One check per scored repo: success at or above the level, else failure with the rules that keep it below."""
25
+ need = LEVELS.index(level)
26
+ out = []
27
+ for r in catalog.get("repos") or []:
28
+ score = r.get("score")
29
+ if r.get("archived") or r.get("fork") or not score:
30
+ continue
31
+ got = LEVELS.index(score["level"]) if score.get("level") in LEVELS else -1
32
+ blocking = [x for x in score.get("results") or [] if x["result"] == "fail" and x["level"] in LEVELS[:need + 1]]
33
+ ok = got >= need
34
+ lines = [f"Meets {level}." if ok else f"Below {level} (now {score.get('level') or 'no level'})."]
35
+ lines += [f"- `{x['id']}` ({x['level']}): {x['evidence']}" for x in blocking]
36
+ out.append({"repo": r["name"], "branch": r.get("default_branch"), "conclusion": "success" if ok else "failure",
37
+ "title": f"{'Meets' if ok else 'Below'} {level}", "summary": "\n".join(lines)})
38
+ return out
39
+
40
+
41
+ def send(api, checks: list[dict], cap: int = CAP) -> dict:
42
+ """Posts each check on the default branch's head and the open pull requests' heads, up to `cap` writes."""
43
+ posted, refused, stopped, repos = 0, [], False, 0
44
+ for c in checks:
45
+ try:
46
+ heads = []
47
+ if c["branch"]:
48
+ heads.append(api._get(f"/repos/{c['repo']}/commits/{c['branch']}")["sha"])
49
+ heads += [p["head"]["sha"] for p in api._get(f"/repos/{c['repo']}/pulls", {"state": "open", "per_page": PRS}) or []]
50
+ for sha in heads:
51
+ if posted >= cap:
52
+ stopped = True
53
+ break
54
+ api.send("POST", f"/repos/{c['repo']}/check-runs",
55
+ {"name": NAME, "head_sha": sha, "status": "completed", "conclusion": c["conclusion"],
56
+ "output": {"title": c["title"], "summary": c["summary"]}})
57
+ posted += 1
58
+ except (APIError, OutOfBudget) as exc:
59
+ refused.append(f"{c['repo']}: {str(exc)[:120]}")
60
+ if stopped:
61
+ break
62
+ repos += 1
63
+ return {"posted": posted, "refused": refused, "stopped": stopped, "repos": repos}