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,132 @@
1
+ """Repo-level checks (Scorecard's Branch-Protection in tiers, Security-Policy, Signed-Releases and SBOM, Binary-Artifacts,
2
+ Fuzzing and the Best Practices badge), each from what GitHub says; what the token can't read is "unknown", never a pass."""
3
+ import re
4
+
5
+ from stackvitals.ci.api import APIError
6
+
7
+ # Scorecard's own list (checks/raw/binary_artifact.go), so the two agree on what a binary is.
8
+ BINARY = re.compile(r"\.(crx|deb|dex|dey|elf|o|a|so|macho|iso|class|jar|bundle|dylib|lib|msi|dll|drv|efi|exe|ocx|pyc|pyo"
9
+ r"|par|rpm|wasm|whl)$", re.I)
10
+ GRADLE_WRAPPER = "gradle/wrapper/gradle-wrapper.jar"
11
+ SIGNED = re.compile(r"\.(sig|asc|minisig|sigstore|sigstore\.json|intoto\.jsonl|pem)$", re.I)
12
+ SBOM = re.compile(r"(sbom|\.spdx(\.json)?$|\.cdx\.(json|xml)$|cyclonedx|bom\.(json|xml)$)", re.I)
13
+ BADGE_API = "https://www.bestpractices.dev/projects.json"
14
+ POLICY = ("SECURITY.md", ".github/SECURITY.md", "docs/SECURITY.md", "SECURITY.rst", ".github/SECURITY.rst")
15
+ # Real fuzzers only: ClusterFuzzLite, cargo-fuzz targets, libFuzzer sources. A folder merely named "fuzz" (scripts that
16
+ # compare outputs) isn't one, as a side-by-side with Scorecard showed.
17
+ FUZZ_PATHS = re.compile(r"(^|/)\.clusterfuzzlite/|(^|/)fuzz/(fuzz_targets/)?[^/]+\.rs$|_fuzzer\.(c|cc|cpp)$"
18
+ r"|(^|/)fuzzers?/[^/]+_fuzzer\.(c|cc|cpp|go|py)$|fuzz[^/]*_test\.go$|(^|/)hfuzz_", re.I)
19
+
20
+
21
+ def _try(api, path: str, params: dict | None = None):
22
+ try:
23
+ return api._get(path, params)
24
+ except APIError:
25
+ return None
26
+
27
+
28
+ def branch_tiers(protection: dict | None, rules: list[dict] | None, summary: dict | None = None) -> dict:
29
+ """Scorecard's five tiers, each "met", "missing" or "unknown". Classic protection needs an admin token to read in
30
+ full; rulesets are readable by anyone who can read the repo; and the branch's public summary (GET branches/{b}) says
31
+ whether classic protection is on and which checks it requires, so without admin those two tiers are still known,
32
+ where Scorecard counts what it can't read as met."""
33
+ kinds = {r.get("type"): r.get("parameters") or {} for r in rules or [] if isinstance(r, dict)}
34
+ p = protection or {}
35
+ pr_rule = kinds.get("pull_request") or {}
36
+ classic_reviews = p.get("required_pull_request_reviews") or {}
37
+ count = classic_reviews.get("required_approving_review_count") or pr_rule.get("required_approving_review_count") or 0
38
+ owners = classic_reviews.get("require_code_owner_reviews") or pr_rule.get("require_code_owner_review")
39
+ stale = classic_reviews.get("dismiss_stale_reviews") or pr_rule.get("dismiss_stale_reviews_on_push")
40
+ full = protection is not None
41
+ s = ((summary or {}).get("protection") or {}) if summary else {}
42
+ classic_on = bool(s.get("enabled"))
43
+ checks = (s.get("required_status_checks") or {})
44
+ required = bool(checks.get("contexts") or checks.get("checks")) and checks.get("enforcement_level") != "off"
45
+
46
+ def tier(ok_full, ok_rules, ok_summary=None):
47
+ # Full classic settings decide; else rulesets; else the public summary; else nothing is known.
48
+ if full:
49
+ return "met" if ok_full else "missing"
50
+ if ok_rules:
51
+ return "met"
52
+ if ok_summary is not None:
53
+ return "met" if ok_summary else "missing"
54
+ # Classic protection is on but unreadable without admin: it may hold this rule, so it isn't called missing.
55
+ return "missing" if rules and not classic_on else "unknown"
56
+ tiers = [
57
+ ("no force pushes or deletion", tier(not (p.get("allow_force_pushes") or {}).get("enabled", True)
58
+ and not (p.get("allow_deletions") or {}).get("enabled", True),
59
+ "non_fast_forward" in kinds and "deletion" in kinds,
60
+ # Classic protection blocks both unless an admin allowed them.
61
+ True if classic_on else None)),
62
+ ("reviews required", tier(bool(classic_reviews) and count >= 1, bool(pr_rule) and count >= 1)),
63
+ ("status checks required", tier(bool(p.get("required_status_checks")), "required_status_checks" in kinds,
64
+ required if summary else None)),
65
+ ("two reviewers and code owners", tier(count >= 2 and bool(owners), count >= 2 and bool(owners))),
66
+ ("stale reviews dismissed, admins included", tier(bool(stale) and (p.get("enforce_admins") or {}).get("enabled"),
67
+ bool(stale) and bool(rules))),
68
+ ]
69
+ known = full or bool(rules) or classic_on
70
+ out = [{"tier": i + 1, "rule": name, "state": state} for i, (name, state) in enumerate(tiers)]
71
+ met = 0
72
+ for t in out:
73
+ if t["state"] != "met":
74
+ break
75
+ met += 1
76
+ return {"tiers": out, "level": met, "readable": known}
77
+
78
+
79
+ def policy(api, repo: str, owner: str) -> dict:
80
+ """SECURITY.md in the repo or the owner's .github repo, its contact and disclosure words; private reporting on."""
81
+ text, where = None, None
82
+ for repo_name, path in [(repo, p) for p in POLICY] + [(f"{owner}/.github", "SECURITY.md")]:
83
+ got = _try(api, f"/repos/{repo_name}/contents/{path}")
84
+ if isinstance(got, dict) and got.get("content"):
85
+ import base64
86
+ text, where = base64.b64decode(got["content"]).decode("utf-8", "replace"), f"{repo_name}:{path}"
87
+ break
88
+ reporting = _try(api, f"/repos/{repo}/private-vulnerability-reporting")
89
+ low = (text or "").lower()
90
+ return {"file": where, "contact": bool(re.search(r"@|https?://|security advisor", low)),
91
+ "disclosure": bool(re.search(r"disclos|vulnerab|days|report", low)),
92
+ "private_reporting": (reporting or {}).get("enabled") if isinstance(reporting, dict) else None}
93
+
94
+
95
+ def releases(rels: list[dict]) -> dict:
96
+ """The last five releases' assets: signed or attested ones, and an SBOM among them."""
97
+ last = [r for r in rels or [] if not r.get("draft")][:5]
98
+ signed = [r.get("tag_name") for r in last if any(SIGNED.search(a.get("name") or "") for a in r.get("assets") or [])]
99
+ sbom = [r.get("tag_name") for r in last if any(SBOM.search(a.get("name") or "") for a in r.get("assets") or [])]
100
+ return {"releases": len(last), "signed": signed, "sbom": sbom}
101
+
102
+
103
+ def tree_facts(paths: list[str]) -> dict:
104
+ """Committed binaries (Gradle's wrapper jar left out, as Scorecard leaves it), SBOM files and fuzzing setups."""
105
+ return {"binaries": sorted(p for p in paths if BINARY.search(p) and not p.endswith(GRADLE_WRAPPER))[:50],
106
+ "sbom_files": sorted(p for p in paths if SBOM.search(p.rsplit("/", 1)[-1]))[:10],
107
+ "fuzzing": sorted({p for p in paths if FUZZ_PATHS.search(p)})[:10]}
108
+
109
+
110
+ # Executable file headers: ELF, Mach-O (both byte orders, fat), Windows PE.
111
+ MAGIC = (b"\x7f\x45\x4c\x46", b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf", b"\xce\xfa\xed\xfe", b"\xcf\xfa\xed\xfe", b"\xca\xfe\xba\xbe", b"MZ")
112
+ MAX_SNIFF, SNIFF_BYTES = 10, 20_000_000
113
+
114
+
115
+ def sniff_candidates(entries: list[dict]) -> list[dict]:
116
+ """Executable-mode files with no extension (where Scorecard's name list sees nothing), small enough to read."""
117
+ return [e for e in entries if e.get("type") == "blob" and e.get("mode") == "100755"
118
+ and "." not in e["path"].rsplit("/", 1)[-1] and 1024 <= (e.get("size") or 0) <= SNIFF_BYTES][:MAX_SNIFF]
119
+
120
+
121
+ def is_executable(head: bytes) -> bool:
122
+ """Whether a file's first bytes are an executable's (a script starting with #! is not)."""
123
+ return any(head.startswith(m) for m in MAGIC)
124
+
125
+
126
+ def badge(get_json, repo_url: str) -> str | None:
127
+ """The OpenSSF Best Practices badge level (passing, silver, gold) from bestpractices.dev, else None."""
128
+ try:
129
+ found = get_json(f"{BADGE_API}?url={repo_url}") or []
130
+ except Exception: # noqa: BLE001 - a lookup that fails says nothing either way
131
+ return None
132
+ return (found[0] or {}).get("badge_level") if isinstance(found, list) and found else None
@@ -0,0 +1,111 @@
1
+ """Workflow checks (Scorecard's Dangerous-Workflow, Token-Permissions, Pinned-Dependencies, SAST and Packaging), read
2
+ from the workflow files themselves. Unlike Scorecard, a checkout of fork code behind a guard (a label, an actor or an
3
+ author-association test) is reported as guarded, not as a hole."""
4
+ import re
5
+
6
+ import yaml
7
+
8
+ # Text an outsider controls; inside a run: script it can run commands (script injection).
9
+ UNTRUSTED = re.compile(r"\$\{\{\s*(github\.event\.(issue\.(title|body)|pull_request\.(title|body|head\.ref|head\.label)"
10
+ r"|comment\.body|review\.body|review_comment\.body|discussion\.(title|body)|head_commit\.(message|author\.(name|email))"
11
+ r"|commits\[[^\]]*\]\.(message|author\.(name|email))|pages\[[^\]]*\]\.page_name|workflow_run\.(head_branch|display_title|head_commit\.message))"
12
+ r"|github\.head_ref)\s*\}\}")
13
+ FORK_REF = re.compile(r"github\.event\.pull_request\.head\.(sha|ref)|github\.head_ref|github\.event\.workflow_run\.head_(sha|branch)"
14
+ r"|refs/pull/")
15
+ GUARD = re.compile(r"labels|label\.name|github\.actor|author_association|github\.event\.pull_request\.head\.repo\.full_name\s*==|"
16
+ r"github\.repository_owner|environment")
17
+ PIN = re.compile(r"^[0-9a-f]{40}$")
18
+ CURL_PIPE = re.compile(r"(curl|wget)\b[^|\n]*\|\s*(sudo\s+)?(ba|z|da)?sh\b")
19
+ # Installs that take whatever version is newest (Scorecard's Pinned-Dependencies counts these too): pip without a
20
+ # requirements file, a pinned version or hashes, and global npm installs without a version.
21
+ PIP_LOOSE = re.compile(r"\bpip3?\s+install\s+(?!.*(-r\s|--requirement|--require-hashes|-e\s|\s\.(\s|$)|==))[^\n]*")
22
+ NPM_GLOBAL = re.compile(r"\bnpm\s+(install|i)\s+(-g|--global)\s+(?![^\s@]+@\d)[^\n]*")
23
+ FROM_LINE = re.compile(r"(?mi)^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)")
24
+ SAST = ("github/codeql-action", "semgrep", "sonarsource/sonarcloud", "snyk/actions", "returntocorp/semgrep")
25
+ PUBLISH = re.compile(r"npm publish|pypa/gh-action-pypi-publish|twine upload|goreleaser|docker/build-push-action|cargo publish"
26
+ r"|gem push|dotnet nuget push|gradle.*publish|mvn.*deploy|uv publish|poetry publish")
27
+
28
+
29
+ def _load(text: str) -> dict | None:
30
+ try:
31
+ doc = yaml.safe_load(text)
32
+ except yaml.YAMLError:
33
+ return None
34
+ return doc if isinstance(doc, dict) else None
35
+
36
+
37
+ def _events(doc: dict) -> set[str]:
38
+ on = doc.get("on", doc.get(True)) # YAML 1.1 reads a bare `on` as true
39
+ if isinstance(on, str):
40
+ return {on}
41
+ return set(on) if isinstance(on, (list, dict)) else set()
42
+
43
+
44
+ def _steps(job: dict) -> list[dict]:
45
+ return [s for s in job.get("steps") or [] if isinstance(s, dict)]
46
+
47
+
48
+ def check(path: str, text: str) -> list[dict]:
49
+ """Findings in one workflow file: {"probe", "path", "job", "line" text, "detail", "guarded"}."""
50
+ doc = _load(text)
51
+ if doc is None:
52
+ return []
53
+ out = []
54
+ events = _events(doc)
55
+ privileged = events & {"pull_request_target", "workflow_run"}
56
+ for name, job in (doc.get("jobs") or {}).items():
57
+ if not isinstance(job, dict):
58
+ continue
59
+ guard = str(job.get("if") or "")
60
+ for step in _steps(job):
61
+ uses, run, with_ = str(step.get("uses") or ""), str(step.get("run") or ""), step.get("with") or {}
62
+ if privileged and uses.startswith("actions/checkout") and FORK_REF.search(str(with_.get("ref") or "")):
63
+ why = GUARD.search(guard + " " + str(step.get("if") or ""))
64
+ out.append({"probe": "hasDangerousWorkflowUntrustedCheckout", "path": path, "job": name,
65
+ "detail": f"{'/'.join(sorted(privileged))} checks out the pull request's code",
66
+ "guarded": f"only when `{(guard or step.get('if'))}`" if why else None})
67
+ for m in UNTRUSTED.finditer(run):
68
+ out.append({"probe": "hasDangerousWorkflowScriptInjection", "path": path, "job": name, "detail": m.group(0),
69
+ "guarded": None})
70
+ if uses and not uses.startswith(("./", "docker://")) and "@" in uses and not PIN.match(uses.rsplit("@", 1)[1]):
71
+ out.append({"probe": "pinsDependencies", "path": path, "job": name, "detail": uses, "guarded": None})
72
+ for rx in (CURL_PIPE, PIP_LOOSE, NPM_GLOBAL):
73
+ if (m := rx.search(run)):
74
+ out.append({"probe": "pinsDependencies", "path": path, "job": name, "detail": m.group(0).strip()[:120],
75
+ "guarded": None})
76
+ top = doc.get("permissions")
77
+ if top is None or top == "write-all" or (isinstance(top, dict) and any(v == "write" for v in top.values())):
78
+ jobs_ok = all(isinstance(j, dict) and j.get("permissions") is not None for j in (doc.get("jobs") or {}).values())
79
+ if not (top is None and jobs_ok):
80
+ out.append({"probe": "topLevelPermissions", "path": path, "job": None,
81
+ "detail": "no top-level permissions (the token gets the repo's default)" if top is None
82
+ else f"top-level permissions: {top}", "guarded": None})
83
+ return out
84
+
85
+
86
+ def dockerfile(path: str, text: str) -> list[dict]:
87
+ """Base images used by tag rather than digest (FROM image:tag, not image@sha256:...); scratch and earlier build
88
+ stages left out."""
89
+ stages, out = set(), []
90
+ for line in (text or "").splitlines():
91
+ m = FROM_LINE.match(line)
92
+ if not m:
93
+ continue
94
+ image = m.group(1)
95
+ alias = line.split()[-1].lower() if " as " in line.lower() else None
96
+ if image.lower() != "scratch" and image.lower() not in stages and "@sha256:" not in image:
97
+ out.append({"probe": "pinsDependencies", "path": path, "job": None, "detail": f"FROM {image}", "guarded": None})
98
+ if alias:
99
+ stages.add(alias)
100
+ return out
101
+
102
+
103
+ def uses_any(text: str, names) -> bool:
104
+ low = (text or "").lower()
105
+ return any(n in low for n in names)
106
+
107
+
108
+ def facts(files: dict[str, str]) -> dict:
109
+ """What the workflows say about SAST and publishing: {"sast": [paths], "publish": [paths]}."""
110
+ return {"sast": sorted(p for p, t in files.items() if uses_any(t, SAST)),
111
+ "publish": sorted(p for p, t in files.items() if PUBLISH.search(t or ""))}
stackvitals/shell.py ADDED
@@ -0,0 +1,107 @@
1
+ """One look for every module's static page: the header, the styles, table search and trend charts drawn from tables."""
2
+ import html
3
+ from pathlib import Path
4
+
5
+ MODULES = (("ci.html", "CI health"), ("flow.html", "Team Flow"), ("delivery.html", "Delivery"), ("insights.html", "Insights"),
6
+ ("community.html", "Community"), ("portal.html", "Portal"), ("explore.html", "Explore"))
7
+ # Each printable page's section in the app (index.html#/<route>).
8
+ APP_ROUTES = {"ci.html": "ci", "flow.html": "prs", "delivery.html": "delivery", "insights.html": "insights",
9
+ "community.html": "community", "portal.html": "portal", "explore.html": "explore"}
10
+ # Pinned with its integrity hash (from cdnjs), so a changed file never runs; without it the tables still show the numbers.
11
+ CHART_JS = "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.5.1/chart.umd.min.js"
12
+ CHART_SRI = "sha512-WoViKhKD4qI2WruSZqv9+kvM4WfFhUMQCLN4QlDTt5aU56fLQy2gYoxWIqlEnXqJy/+Ac5q/hk1oWfqnMDhwMA=="
13
+
14
+ CSS = """:root{color-scheme:light dark;--bg:#fff;--fg:#1f2328;--mut:#59636e;--line:#d1d9e0;--bad:#cf222e;--warn:#9a6700;--ok:#1a7f37;--card:#f6f8fa;--link:#0969da;--c1:#0969da;--c2:#bf3989;--c3:#1a7f37}
15
+ @media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--mut:#9198a1;--line:#3d444d;--bad:#ff7b72;--warn:#d29922;--ok:#3fb950;--card:#151b23;--link:#4493f8;--c1:#4493f8;--c2:#db61a2;--c3:#3fb950}}
16
+ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}a{color:var(--link)}
17
+ .top{border-bottom:1px solid var(--line);background:var(--card)}.top nav{max-width:1100px;margin:0 auto;padding:10px 16px;display:flex;flex-wrap:wrap;gap:4px 18px;align-items:center}
18
+ .top a.app{margin-left:auto}.top b{margin-right:6px}.top a{text-decoration:none;padding:2px 0}.top a[aria-current]{color:var(--fg);font-weight:600;border-bottom:2px solid var(--c1)}
19
+ main{max-width:1100px;margin:0 auto;padding:20px 16px}h1{font-size:22px;margin:0 0 4px;overflow-wrap:anywhere}h2{font-size:17px;margin:28px 0 8px}
20
+ .mut{color:var(--mut)}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-top:16px}
21
+ .card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px 12px}.card b{display:block;font-size:20px}
22
+ .tbl{overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:14px}th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
23
+ th{color:var(--mut);font-weight:600}td{overflow-wrap:break-word}.tag{font-size:12px;padding:1px 7px;border-radius:10px;border:1px solid currentColor;white-space:nowrap}
24
+ .nw{white-space:nowrap}.flaky,.broken{color:var(--bad)}.suspect{color:var(--warn)}.fixed,.ok{color:var(--ok)}footer{margin-top:32px;font-size:13px}
25
+ .find-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:4px 0 8px}.find-bar input,.find-bar select{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:4px 8px}
26
+ .find-bar input{flex:1 1 200px;max-width:320px}.charts{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px}
27
+ .chart{margin:0;border:1px solid var(--line);border-radius:8px;padding:10px 12px;min-width:0}.chart figcaption{font-weight:600;margin-bottom:6px}
28
+ .chart .box{position:relative;height:220px;display:none}.chart.drawn .box{display:block}.chart summary{color:var(--mut);font-size:13px;cursor:pointer}"""
29
+
30
+ # Plain JavaScript: a search box (and a filter for marked columns) on long tables, then each chart drawn from its table.
31
+ SCRIPT = r"""<script>(function(){
32
+ function rows(t){return [].filter.call(t.rows,function(r){return !r.querySelector('th')})}
33
+ function find(t){var body=rows(t);if(body.length<6)return;
34
+ var bar=document.createElement('div');bar.className='find-bar';
35
+ var q=document.createElement('input');q.type='search';q.placeholder='Filter rows';q.setAttribute('aria-label','Filter rows');bar.appendChild(q);
36
+ var picks=[];[].forEach.call(t.rows[0].cells,function(h,i){if(!h.hasAttribute('data-filter'))return;
37
+ var s=document.createElement('select'),seen={};s.setAttribute('aria-label','Filter by '+h.textContent);s.add(new Option('Any '+h.textContent.toLowerCase(),''));
38
+ body.forEach(function(r){var v=r.cells[i]?r.cells[i].textContent.trim():'';if(v&&!seen[v]){seen[v]=1;s.add(new Option(v,v))}});
39
+ picks.push([s,i]);bar.appendChild(s)});
40
+ var n=document.createElement('span');n.className='mut';n.setAttribute('aria-live','polite');bar.appendChild(n);
41
+ function apply(){var w=q.value.trim().toLowerCase(),shown=0;body.forEach(function(r){
42
+ var ok=!w||r.textContent.toLowerCase().indexOf(w)>=0;picks.forEach(function(p){if(p[0].value&&(r.cells[p[1]]?r.cells[p[1]].textContent.trim():'')!==p[0].value)ok=false});
43
+ r.hidden=!ok;shown+=ok?1:0});n.textContent=shown+' of '+body.length+' rows'}
44
+ q.addEventListener('input',apply);picks.forEach(function(p){p[0].addEventListener('change',apply)});
45
+ var at=t.closest('.tbl')||t;at.parentNode.insertBefore(bar,at);apply()}
46
+ function draw(){if(!window.Chart)return;var cs=getComputedStyle(document.documentElement),v=function(k){return cs.getPropertyValue(k).trim()};
47
+ Chart.defaults.color=v('--mut');Chart.defaults.borderColor=v('--line');Chart.defaults.font.family=getComputedStyle(document.body).fontFamily;
48
+ var colors=[v('--c1'),v('--c2'),v('--c3')];
49
+ document.querySelectorAll('figure[data-chart]').forEach(function(f){var t=f.querySelector('table'),body=rows(t),head=[].slice.call(t.rows[0].cells,1);
50
+ var sets=head.map(function(h,i){var c=colors[i%colors.length];return{label:h.textContent,borderColor:c,backgroundColor:c,spanGaps:false,cubicInterpolationMode:'monotone',pointRadius:2,
51
+ data:body.map(function(r){var x=r.cells[i+1].getAttribute('data-v');return x===null||x===''?null:+x}),text:body.map(function(r){return r.cells[i+1].textContent})}});
52
+ f.classList.add('drawn');try{new Chart(f.querySelector('canvas'),{type:f.getAttribute('data-chart'),data:{labels:body.map(function(r){return r.cells[0].textContent}),datasets:sets},
53
+ options:{animation:false,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},scales:{y:{beginAtZero:true,title:{display:!!f.dataset.y,text:f.dataset.y}}},
54
+ plugins:{legend:{display:sets.length>1},tooltip:{callbacks:{label:function(c){return c.dataset.label+': '+c.dataset.text[c.dataIndex]}}}}}});
55
+ f.querySelector('details').open=false}catch(e){f.classList.remove('drawn')}})}
56
+ document.addEventListener('DOMContentLoaded',function(){document.querySelectorAll('table.find').forEach(find);draw()})})()</script>"""
57
+
58
+
59
+ def present(docs: Path) -> set[str]:
60
+ """The module pages (and the app) already in this docs folder, so the header never links to a page that isn't there."""
61
+ return {name for name in [*(m for m, _ in MODULES), "index.html"] if (Path(docs) / name).exists()}
62
+
63
+
64
+ def nav(current: str | None, pages=(), root: str = "", app_route: str | None = None) -> str:
65
+ links = []
66
+ for name, label in MODULES:
67
+ if name == current:
68
+ links.append(f"<a href='{root}{name}' aria-current=page>{label}</a>")
69
+ elif name in pages:
70
+ links.append(f"<a href='{root}{name}'>{label}</a>")
71
+ # These pages are the printable view; the app (index.html, when it's beside them) is where the decisions live.
72
+ route = html.escape(app_route if app_route is not None else APP_ROUTES.get(current, ""), quote=True)
73
+ app = "index.html" in pages
74
+ brand = f"<a href='{root}index.html'>StackVitals</a>" if app else "StackVitals"
75
+ return (f"<header class=top><nav aria-label='StackVitals pages'><b>{brand}</b>{''.join(links)}"
76
+ + (f"<a class=app href='{root}index.html#/{route}'>Open in the app</a>" if app else "") + "</nav></header>")
77
+
78
+
79
+ def page(title: str, repo: str, current: str | None, sub: str, body: str, pages=(), root: str = "",
80
+ app_route: str | None = None) -> str:
81
+ """A whole page; `sub` and `body` are HTML the caller already escaped."""
82
+ e = html.escape
83
+ charts = (f"<script defer src='{CHART_JS}' integrity='{CHART_SRI}' crossorigin=anonymous referrerpolicy=no-referrer></script>"
84
+ if "data-chart=" in body else "")
85
+ return (f"<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>"
86
+ f"<title>{e(title)}: {e(repo)}</title><style>{CSS}</style>{charts}</head><body>{nav(current, pages, root, app_route)}<main>"
87
+ f"<h1>{e(title)}: {e(repo)}</h1>" + (f"<div class=mut>{sub}</div>" if sub else "") + body + f"</main>{SCRIPT}</body></html>")
88
+
89
+
90
+ def _v(value) -> str:
91
+ return "" if value is None else str(value)
92
+
93
+
94
+ def chart(title: str, x: str, labels: list[str], series: list[tuple[str, list]], y: str = "", fmt=str, kind: str = "line") -> str:
95
+ """A chart drawn in the browser from the table below it, so its points are the table's numbers; no JavaScript, the table."""
96
+ e = html.escape
97
+ head = f"<tr><th>{e(x)}</th>" + "".join(f"<th>{e(name)}</th>" for name, _ in series) + "</tr>"
98
+ rows = "".join(f"<tr><td>{e(str(label))}</td>" + "".join(
99
+ f"<td data-v='{_v(values[i])}'>{e('-' if values[i] is None else fmt(values[i]))}</td>" for _, values in series) + "</tr>"
100
+ for i, label in enumerate(labels))
101
+ return (f"<figure class=chart data-chart={kind} data-y='{e(y)}'><figcaption>{e(title)}</figcaption>"
102
+ f"<div class=box><canvas role=img aria-label='{e(title)}'></canvas></div>"
103
+ f"<details open><summary>Numbers</summary><div class=tbl><table>{head}{rows}</table></div></details></figure>")
104
+
105
+
106
+ def charts(figures: list[str]) -> str:
107
+ return f"<div class=charts>{''.join(figures)}</div>" if figures else ""
stackvitals/site.py ADDED
@@ -0,0 +1,51 @@
1
+ """The project site: a static landing page, the app itself (app.html: Welcome and Setup on first open, ?demo=1 for three
2
+ example repos) and, given a repo's docs folder, that repo's real data under demo/, built from the package into one static
3
+ folder (`stackvitals site build --out DIR --demo DOCS`)."""
4
+ import argparse
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from stackvitals.ci.cli import ACTION_REF
10
+ from stackvitals.core.webapp import files, site_files, stamped, write_webapp
11
+
12
+ REAL_DEMO = b"<!--REAL-DEMO-->"
13
+
14
+
15
+ def build(out: Path, demo: Path | None = None, ref: str = ACTION_REF) -> list[str]:
16
+ """Write the site into `out`: index.html (landing), app.html and app/ (the app), setup.html and integrations.html
17
+ (forwards from the earlier site's addresses), site/ styles, and demo/ (the app on a copy of `demo`, a docs folder).
18
+ `ref` is kept for the command line; the app's Setup writes the workflow with the release it was built for."""
19
+ out = Path(out)
20
+ app = files()
21
+ pages = {**{name: data for name, data in app.items() if name != "index.html"}, "app.html": app["index.html"], **site_files()}
22
+ link = b'<a class="btn big" href="demo/">A real repo\'s data</a>' if demo else b""
23
+ pages["index.html"] = pages["index.html"].replace(REAL_DEMO, link)
24
+ for name, data in stamped(pages).items():
25
+ target = out / name
26
+ target.parent.mkdir(parents=True, exist_ok=True)
27
+ target.write_bytes(data)
28
+ if demo:
29
+ target = out / "demo"
30
+ if target.exists():
31
+ shutil.rmtree(target)
32
+ shutil.copytree(demo, target, ignore=shutil.ignore_patterns("app", "index.html"))
33
+ write_webapp(target)
34
+ return sorted(pages)
35
+
36
+
37
+ def main(argv: list[str] | None = None) -> int:
38
+ parser = argparse.ArgumentParser(prog="stackvitals site", description="Build the StackVitals project site.")
39
+ sub = parser.add_subparsers(dest="cmd", required=True)
40
+ b = sub.add_parser("build", help="landing page, the app and an optional real-data demo into one folder")
41
+ b.add_argument("--out", required=True, help="folder to write")
42
+ b.add_argument("--demo", help="a repo's docs folder (from its stackvitals-data branch) to show as a real-data demo")
43
+ b.add_argument("--ref", default=ACTION_REF, help=argparse.SUPPRESS) # kept so older build scripts still run
44
+ args = parser.parse_args(argv)
45
+ demo = Path(args.demo) if args.demo else None
46
+ if demo and not (demo / "app.json").exists():
47
+ print(f"{demo} has no app.json; give the docs folder of a repo's stackvitals-data branch.", file=sys.stderr)
48
+ return 2
49
+ written = build(Path(args.out), demo, args.ref)
50
+ print(f"Site written to {args.out} ({len(written)} files{', with a real-data demo' if demo else ''}).")
51
+ return 0