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.
- mergescout/__init__.py +1 -0
- mergescout/archive/__init__.py +0 -0
- mergescout/archive/__main__.py +30 -0
- mergescout/archive/convert.py +83 -0
- mergescout/archive/events.py +55 -0
- mergescout/archive/manifest.py +45 -0
- mergescout/archive/stream.py +83 -0
- mergescout/bench/__init__.py +1 -0
- mergescout/bench/__main__.py +135 -0
- mergescout/bench/ci.py +257 -0
- mergescout/bench/deploys.py +115 -0
- mergescout/bench/matched.py +22 -0
- mergescout/bench/prior.py +42 -0
- mergescout/bench/report.py +230 -0
- mergescout/bench/shared.py +112 -0
- mergescout/bench/stars.py +57 -0
- mergescout/cli.py +89 -0
- mergescout/config.py +35 -0
- mergescout/crawl/__init__.py +0 -0
- mergescout/crawl/__main__.py +97 -0
- mergescout/crawl/chain.py +34 -0
- mergescout/crawl/fetch.py +146 -0
- mergescout/crawl/outcomes.py +69 -0
- mergescout/crawl/queries.py +250 -0
- mergescout/crawl/runner.py +176 -0
- mergescout/crawl/seed.py +77 -0
- mergescout/crawl/state.py +169 -0
- mergescout/crawl/writer.py +87 -0
- mergescout/discover/__init__.py +0 -0
- mergescout/discover/__main__.py +58 -0
- mergescout/discover/sweep.py +286 -0
- mergescout/github/__init__.py +0 -0
- mergescout/github/graphql.py +202 -0
- mergescout/github/identities.py +35 -0
- mergescout/github/releases.py +137 -0
- mergescout/labels.py +20 -0
- mergescout/mcp_server.py +228 -0
- mergescout/measure/__init__.py +0 -0
- mergescout/measure/__main__.py +75 -0
- mergescout/measure/api_cost.py +91 -0
- mergescout/measure/count_repos.py +30 -0
- mergescout/measure/jsonl.py +14 -0
- mergescout/measure/report.py +56 -0
- mergescout/measure/sample.py +37 -0
- mergescout/owners.py +22 -0
- mergescout/setup_check.py +64 -0
- mergescout/signals/__init__.py +0 -0
- mergescout/signals/__main__.py +96 -0
- mergescout/signals/bump_ledger.py +258 -0
- mergescout/signals/bumps.py +104 -0
- mergescout/signals/check.py +141 -0
- mergescout/signals/community_bench.py +149 -0
- mergescout/signals/delivery_bench.py +103 -0
- mergescout/signals/deps_map.py +261 -0
- mergescout/signals/deps_trends.py +117 -0
- mergescout/signals/evidence.py +94 -0
- mergescout/signals/exploits.py +43 -0
- mergescout/signals/flow_bench.py +258 -0
- mergescout/signals/identity.py +73 -0
- mergescout/signals/issues.py +76 -0
- mergescout/signals/labels.py +79 -0
- mergescout/signals/osv.py +232 -0
- mergescout/signals/owners_watch.py +80 -0
- mergescout/signals/repo_stats.py +96 -0
- mergescout/signals/rules.py +70 -0
- mergescout/signals/silver.py +33 -0
- mergescout/signals/top_packages.py +26 -0
- mergescout/signals/triage.py +71 -0
- mergescout/signals/triage_math.py +120 -0
- mergescout/signals/walls.py +91 -0
- mergescout/snapshot/__init__.py +0 -0
- mergescout/snapshot/__main__.py +215 -0
- mergescout/snapshot/build.py +147 -0
- mergescout/snapshot/publish.py +70 -0
- mergescout/snapshot/schema.py +34 -0
- mergescout/snapshot/score.py +30 -0
- mergescout/store/__init__.py +0 -0
- mergescout/store/__main__.py +46 -0
- mergescout/store/sync.py +148 -0
- mergescout/timeutil.py +11 -0
- mergescout/tool/__init__.py +0 -0
- mergescout/tool/action_pack.py +62 -0
- mergescout/tool/live.py +201 -0
- mergescout/tool/queries.py +293 -0
- mergescout/tool/refs.py +31 -0
- mergescout/tool/store.py +129 -0
- mergescout/tool/tracking.py +83 -0
- stackvitals/__init__.py +2 -0
- stackvitals/__main__.py +4 -0
- stackvitals/ci/__init__.py +1 -0
- stackvitals/ci/action.py +574 -0
- stackvitals/ci/analyze.py +577 -0
- stackvitals/ci/api.py +218 -0
- stackvitals/ci/automation.py +212 -0
- stackvitals/ci/bench.py +195 -0
- stackvitals/ci/burnin.py +102 -0
- stackvitals/ci/cli.py +570 -0
- stackvitals/ci/collect.py +239 -0
- stackvitals/ci/cost.py +107 -0
- stackvitals/ci/coverage.py +155 -0
- stackvitals/ci/feedback.py +699 -0
- stackvitals/ci/fingerprint.py +220 -0
- stackvitals/ci/gitlab.py +121 -0
- stackvitals/ci/gitlab_action.py +125 -0
- stackvitals/ci/impact.py +125 -0
- stackvitals/ci/investigate.py +59 -0
- stackvitals/ci/kinds.py +52 -0
- stackvitals/ci/monitors.py +105 -0
- stackvitals/ci/owners.py +116 -0
- stackvitals/ci/quarantine_runtime.py +90 -0
- stackvitals/ci/report.py +435 -0
- stackvitals/ci/retry.py +67 -0
- stackvitals/ci/selfheal.py +102 -0
- stackvitals/ci/shards.py +120 -0
- stackvitals/ci/share.py +59 -0
- stackvitals/ci/store.py +93 -0
- stackvitals/ci/testreports.py +351 -0
- stackvitals/ci/tickets.py +106 -0
- stackvitals/ci/track_record.json +11 -0
- stackvitals/ci/triage.py +130 -0
- stackvitals/ci/triggers.py +147 -0
- stackvitals/ci/upload.py +85 -0
- stackvitals/ci/validate.py +91 -0
- stackvitals/cli.py +111 -0
- stackvitals/codeowners.py +69 -0
- stackvitals/community/__init__.py +0 -0
- stackvitals/community/action.py +140 -0
- stackvitals/community/affiliation.py +51 -0
- stackvitals/community/analyze.py +507 -0
- stackvitals/community/build.py +56 -0
- stackvitals/community/chaoss.py +70 -0
- stackvitals/community/cli.py +79 -0
- stackvitals/community/collect.py +240 -0
- stackvitals/community/context.py +54 -0
- stackvitals/community/report.py +250 -0
- stackvitals/connectors/__init__.py +244 -0
- stackvitals/connectors/ai_spend.py +190 -0
- stackvitals/connectors/base.py +82 -0
- stackvitals/connectors/check.py +686 -0
- stackvitals/connectors/copilot.py +104 -0
- stackvitals/connectors/discord.py +103 -0
- stackvitals/connectors/discourse.py +117 -0
- stackvitals/connectors/incident_io.py +106 -0
- stackvitals/connectors/jira.py +210 -0
- stackvitals/connectors/linear.py +140 -0
- stackvitals/connectors/pagerduty.py +96 -0
- stackvitals/connectors/slack.py +110 -0
- stackvitals/connectors/sonar.py +77 -0
- stackvitals/connectors/store.py +120 -0
- stackvitals/connectors/tracker.py +108 -0
- stackvitals/connectors/writeback.py +105 -0
- stackvitals/contribute/__init__.py +1 -0
- stackvitals/contribute/brief.py +297 -0
- stackvitals/contribute/cli.py +64 -0
- stackvitals/core/__init__.py +1 -0
- stackvitals/core/actors.py +103 -0
- stackvitals/core/agent_ledger.py +120 -0
- stackvitals/core/agreements.py +95 -0
- stackvitals/core/ai_outcomes.py +276 -0
- stackvitals/core/alerts.py +224 -0
- stackvitals/core/appdata.py +112 -0
- stackvitals/core/bucket.py +190 -0
- stackvitals/core/bugs.py +43 -0
- stackvitals/core/calendar.py +34 -0
- stackvitals/core/capex.py +57 -0
- stackvitals/core/change_risk.py +181 -0
- stackvitals/core/changes.py +133 -0
- stackvitals/core/chat.py +143 -0
- stackvitals/core/config.py +373 -0
- stackvitals/core/dataflow.py +168 -0
- stackvitals/core/decisions.py +275 -0
- stackvitals/core/delivery.py +954 -0
- stackvitals/core/delivery_peers.py +98 -0
- stackvitals/core/delivery_report.py +429 -0
- stackvitals/core/deliverycli.py +147 -0
- stackvitals/core/devlake_dora.sql +485 -0
- stackvitals/core/digest.py +333 -0
- stackvitals/core/explaincli.py +107 -0
- stackvitals/core/explore.py +296 -0
- stackvitals/core/export.py +342 -0
- stackvitals/core/exportcli.py +117 -0
- stackvitals/core/findings.py +350 -0
- stackvitals/core/health.py +94 -0
- stackvitals/core/identity.py +82 -0
- stackvitals/core/insights.py +365 -0
- stackvitals/core/insightscli.py +43 -0
- stackvitals/core/installdoc.py +69 -0
- stackvitals/core/investment.py +187 -0
- stackvitals/core/joins.py +43 -0
- stackvitals/core/llmsdoc.py +40 -0
- stackvitals/core/merge_queue.py +70 -0
- stackvitals/core/metrics.py +109 -0
- stackvitals/core/model.py +285 -0
- stackvitals/core/projectcli.py +66 -0
- stackvitals/core/propose.py +86 -0
- stackvitals/core/pulse.py +85 -0
- stackvitals/core/recorded.py +145 -0
- stackvitals/core/releases.py +39 -0
- stackvitals/core/requests.py +528 -0
- stackvitals/core/resync.py +35 -0
- stackvitals/core/schema.py +205 -0
- stackvitals/core/services.py +203 -0
- stackvitals/core/settings.py +96 -0
- stackvitals/core/sqlcli.py +111 -0
- stackvitals/core/stats.py +222 -0
- stackvitals/core/store.py +125 -0
- stackvitals/core/summary.py +104 -0
- stackvitals/core/time.py +29 -0
- stackvitals/core/tomledit.py +149 -0
- stackvitals/core/uicli.py +138 -0
- stackvitals/core/webapp.py +79 -0
- stackvitals/core/what_changed.py +76 -0
- stackvitals/core/workspace.py +194 -0
- stackvitals/deps/__init__.py +1 -0
- stackvitals/deps/action.py +299 -0
- stackvitals/deps/actions_vulns.py +89 -0
- stackvitals/deps/cli.py +77 -0
- stackvitals/deps/dossier.py +341 -0
- stackvitals/deps/evidence.py +44 -0
- stackvitals/deps/fixplan.py +40 -0
- stackvitals/deps/guarddog.py +32 -0
- stackvitals/deps/hygiene.py +47 -0
- stackvitals/deps/imports.py +113 -0
- stackvitals/deps/lockfiles.py +198 -0
- stackvitals/deps/lookalike.py +80 -0
- stackvitals/deps/reach.py +59 -0
- stackvitals/deps/release.py +110 -0
- stackvitals/deps/runtimes.py +161 -0
- stackvitals/deps/supply.py +454 -0
- stackvitals/deps/vulns.py +588 -0
- stackvitals/editors/__init__.py +40 -0
- stackvitals/editors/vscode/extension.js +101 -0
- stackvitals/editors/vscode/marks.js +92 -0
- stackvitals/editors/vscode/package.json +26 -0
- stackvitals/flow/__init__.py +1 -0
- stackvitals/flow/action.py +178 -0
- stackvitals/flow/analyze.py +418 -0
- stackvitals/flow/automate.py +72 -0
- stackvitals/flow/build.py +46 -0
- stackvitals/flow/cli.py +93 -0
- stackvitals/flow/code_hosts.py +146 -0
- stackvitals/flow/collect.py +451 -0
- stackvitals/flow/gitlab_mr.py +75 -0
- stackvitals/flow/knowledge.py +202 -0
- stackvitals/flow/peers.py +90 -0
- stackvitals/flow/preflight.py +210 -0
- stackvitals/flow/report.py +253 -0
- stackvitals/flow/rules.py +135 -0
- stackvitals/flow/store.py +108 -0
- stackvitals/flow/survival.py +84 -0
- stackvitals/flow/triage.py +62 -0
- stackvitals/mcp_server.py +670 -0
- stackvitals/portal/__init__.py +0 -0
- stackvitals/portal/action.py +205 -0
- stackvitals/portal/batch.py +80 -0
- stackvitals/portal/campaigns.py +104 -0
- stackvitals/portal/catalog.py +104 -0
- stackvitals/portal/checks.py +63 -0
- stackvitals/portal/cli.py +172 -0
- stackvitals/portal/descriptor.py +105 -0
- stackvitals/portal/export.py +43 -0
- stackvitals/portal/github.py +48 -0
- stackvitals/portal/golden.py +95 -0
- stackvitals/portal/history.py +30 -0
- stackvitals/portal/inventory.py +264 -0
- stackvitals/portal/owners.py +78 -0
- stackvitals/portal/pages.py +153 -0
- stackvitals/portal/rollup.py +32 -0
- stackvitals/portal/scorecard.py +366 -0
- stackvitals/security/__init__.py +2 -0
- stackvitals/security/probes.py +154 -0
- stackvitals/security/repo_checks.py +132 -0
- stackvitals/security/workflows.py +111 -0
- stackvitals/shell.py +107 -0
- stackvitals/site.py +51 -0
- stackvitals/web/app/LICENSES.md +428 -0
- stackvitals/web/app/boot.js +1 -0
- stackvitals/web/app/chunks/AddRepo-KRPZXWCY.js +1 -0
- stackvitals/web/app/chunks/SetupRunning-XWFQAKRZ.js +1 -0
- stackvitals/web/app/chunks/accuracy-data-OQTDXI7Y.js +1 -0
- stackvitals/web/app/chunks/agents-data-BAL6QAOF.js +11 -0
- stackvitals/web/app/chunks/chunk-24LFKFZ6.js +1 -0
- stackvitals/web/app/chunks/chunk-3G4SQNUC.js +1 -0
- stackvitals/web/app/chunks/chunk-3IXWEOIJ.js +154 -0
- stackvitals/web/app/chunks/chunk-3ROQFHPV.js +1 -0
- stackvitals/web/app/chunks/chunk-45PGAQRO.js +8 -0
- stackvitals/web/app/chunks/chunk-4H53OYLG.js +1 -0
- stackvitals/web/app/chunks/chunk-4LUI7O5P.js +1 -0
- stackvitals/web/app/chunks/chunk-4SQPQ5TB.js +1 -0
- stackvitals/web/app/chunks/chunk-4WKQTWUC.js +2 -0
- stackvitals/web/app/chunks/chunk-576YHDEK.js +8 -0
- stackvitals/web/app/chunks/chunk-6BDBXA6V.js +1 -0
- stackvitals/web/app/chunks/chunk-6RLVD2DT.js +1 -0
- stackvitals/web/app/chunks/chunk-763NH7PP.js +1 -0
- stackvitals/web/app/chunks/chunk-76WAVKUG.js +1 -0
- stackvitals/web/app/chunks/chunk-7II2BD5F.js +1 -0
- stackvitals/web/app/chunks/chunk-7TJCBOAY.js +1 -0
- stackvitals/web/app/chunks/chunk-ASYNLVOP.js +1 -0
- stackvitals/web/app/chunks/chunk-CTKDYZMG.js +1 -0
- stackvitals/web/app/chunks/chunk-CVB26NMC.js +1 -0
- stackvitals/web/app/chunks/chunk-G7NGB5UX.js +1 -0
- stackvitals/web/app/chunks/chunk-GPVLJRZD.js +1 -0
- stackvitals/web/app/chunks/chunk-GQYFN4PE.js +75 -0
- stackvitals/web/app/chunks/chunk-GT4EAKAO.js +2 -0
- stackvitals/web/app/chunks/chunk-IYMBFPCV.js +1 -0
- stackvitals/web/app/chunks/chunk-LAAGCPU3.js +1 -0
- stackvitals/web/app/chunks/chunk-MAPYGK3P.js +1 -0
- stackvitals/web/app/chunks/chunk-P5TXDD45.js +1 -0
- stackvitals/web/app/chunks/chunk-PABICGBL.js +1 -0
- stackvitals/web/app/chunks/chunk-RPXHAG6F.js +1 -0
- stackvitals/web/app/chunks/chunk-RQZPSJ2Y.js +1 -0
- stackvitals/web/app/chunks/chunk-S2ZBMEVA.js +1 -0
- stackvitals/web/app/chunks/chunk-S654BBEP.js +6 -0
- stackvitals/web/app/chunks/chunk-T7QVTNXF.js +1 -0
- stackvitals/web/app/chunks/chunk-THFUOCPC.js +273 -0
- stackvitals/web/app/chunks/chunk-TO4VVRZ7.js +1 -0
- stackvitals/web/app/chunks/chunk-VQ5YXO54.js +1 -0
- stackvitals/web/app/chunks/chunk-VWKSMFWJ.js +1 -0
- stackvitals/web/app/chunks/chunk-Y6LUXDM6.js +1 -0
- stackvitals/web/app/chunks/chunk-YTANGYYH.js +1 -0
- stackvitals/web/app/chunks/connect-KONYWYTT.js +1 -0
- stackvitals/web/app/chunks/connections-data-2DYS4UVW.js +32 -0
- stackvitals/web/app/chunks/logos-N5IINUAM.js +1 -0
- stackvitals/web/app/chunks/more-KMJMJQD3.js +10 -0
- stackvitals/web/app/chunks/more-PC6FVS3P.js +1 -0
- stackvitals/web/app/chunks/more-RCIVCUDE.js +2 -0
- stackvitals/web/app/chunks/more-ZKKRY6NO.css +1 -0
- stackvitals/web/app/chunks/sealbox-35YL4EUH.js +3 -0
- stackvitals/web/app/chunks/session-DYYTOE2K.js +1 -0
- stackvitals/web/app/chunks/settingsApply-2HRJEZYM.js +1 -0
- stackvitals/web/app/chunks/settingsReview-GX2FZQ4M.js +3 -0
- stackvitals/web/app/chunks/uPlot.esm-OIWT3AHJ.js +3 -0
- stackvitals/web/app/demo/ci/impact.json +1 -0
- stackvitals/web/app/demo/ci/quarantine.json +1 -0
- stackvitals/web/app/demo/ci/tickets.json +1 -0
- stackvitals/web/app/demo/mobile/alerts.json +1 -0
- stackvitals/web/app/demo/mobile/app.json +1 -0
- stackvitals/web/app/demo/mobile/community.json +1 -0
- stackvitals/web/app/demo/mobile/connectors.json +1 -0
- stackvitals/web/app/demo/mobile/data.json +1 -0
- stackvitals/web/app/demo/mobile/decisions.json +1 -0
- stackvitals/web/app/demo/mobile/delivery.json +1 -0
- stackvitals/web/app/demo/mobile/deps.json +1 -0
- stackvitals/web/app/demo/mobile/flow.json +1 -0
- stackvitals/web/app/demo/mobile/health.json +1 -0
- stackvitals/web/app/demo/mobile/insights.json +1 -0
- stackvitals/web/app/demo/mobile/pulse.json +1 -0
- stackvitals/web/app/demo/mobile/security.json +1 -0
- stackvitals/web/app/demo/mobile/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/mobile/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/mobile/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/mobile/summary/manifest.json +1 -0
- stackvitals/web/app/demo/mobile/summary/prs.jsonl +19 -0
- stackvitals/web/app/demo/mobile/summary/tests.jsonl +1 -0
- stackvitals/web/app/demo/mobile/supply.json +1 -0
- stackvitals/web/app/demo/mobile/vulns.json +1 -0
- stackvitals/web/app/demo/payments-api/alerts.json +1 -0
- stackvitals/web/app/demo/payments-api/app.json +1 -0
- stackvitals/web/app/demo/payments-api/community.json +1 -0
- stackvitals/web/app/demo/payments-api/connectors.json +1 -0
- stackvitals/web/app/demo/payments-api/data.json +1 -0
- stackvitals/web/app/demo/payments-api/decisions.json +1 -0
- stackvitals/web/app/demo/payments-api/delivery.json +1 -0
- stackvitals/web/app/demo/payments-api/deps.json +1 -0
- stackvitals/web/app/demo/payments-api/flow.json +1 -0
- stackvitals/web/app/demo/payments-api/health.json +1 -0
- stackvitals/web/app/demo/payments-api/insights.json +1 -0
- stackvitals/web/app/demo/payments-api/pulse.json +1 -0
- stackvitals/web/app/demo/payments-api/security.json +1 -0
- stackvitals/web/app/demo/payments-api/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/payments-api/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/payments-api/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/payments-api/summary/manifest.json +1 -0
- stackvitals/web/app/demo/payments-api/summary/prs.jsonl +22 -0
- stackvitals/web/app/demo/payments-api/summary/tests.jsonl +3 -0
- stackvitals/web/app/demo/payments-api/supply.json +1 -0
- stackvitals/web/app/demo/payments-api/vulns.json +1 -0
- stackvitals/web/app/demo/portal.json +1 -0
- stackvitals/web/app/demo/settings.json +1 -0
- stackvitals/web/app/demo/web-app/alerts.json +1 -0
- stackvitals/web/app/demo/web-app/app.json +1 -0
- stackvitals/web/app/demo/web-app/community.json +1 -0
- stackvitals/web/app/demo/web-app/connectors.json +1 -0
- stackvitals/web/app/demo/web-app/data.json +1 -0
- stackvitals/web/app/demo/web-app/decisions.json +1 -0
- stackvitals/web/app/demo/web-app/delivery.json +1 -0
- stackvitals/web/app/demo/web-app/deps.json +1 -0
- stackvitals/web/app/demo/web-app/flow.json +1 -0
- stackvitals/web/app/demo/web-app/health.json +1 -0
- stackvitals/web/app/demo/web-app/insights.json +1 -0
- stackvitals/web/app/demo/web-app/pulse.json +1 -0
- stackvitals/web/app/demo/web-app/security.json +1 -0
- stackvitals/web/app/demo/web-app/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/web-app/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/web-app/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/web-app/summary/manifest.json +1 -0
- stackvitals/web/app/demo/web-app/summary/prs.jsonl +18 -0
- stackvitals/web/app/demo/web-app/summary/tests.jsonl +2 -0
- stackvitals/web/app/demo/web-app/supply.json +1 -0
- stackvitals/web/app/demo/web-app/vulns.json +1 -0
- stackvitals/web/app/fonts/plex-mono-400.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-400.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-600.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-condensed-700.woff2 +0 -0
- stackvitals/web/app/help/accuracy.json +1 -0
- stackvitals/web/app/help/connectors.json +1 -0
- stackvitals/web/app/help/getting-started.json +1 -0
- stackvitals/web/app/help/index.json +1 -0
- stackvitals/web/app/help/modules.json +1 -0
- stackvitals/web/app/help/privacy-and-security.json +1 -0
- stackvitals/web/app/help/reference.json +1 -0
- stackvitals/web/app/main.css +1 -0
- stackvitals/web/app/main.js +1 -0
- stackvitals/web/app/pages/about.css +1 -0
- stackvitals/web/app/pages/about.js +1 -0
- stackvitals/web/app/pages/activity.css +1 -0
- stackvitals/web/app/pages/activity.js +1 -0
- stackvitals/web/app/pages/agents.css +1 -0
- stackvitals/web/app/pages/agents.js +2 -0
- stackvitals/web/app/pages/ci.css +1 -0
- stackvitals/web/app/pages/ci.js +1 -0
- stackvitals/web/app/pages/community.css +1 -0
- stackvitals/web/app/pages/community.js +1 -0
- stackvitals/web/app/pages/connections.css +1 -0
- stackvitals/web/app/pages/connections.js +6 -0
- stackvitals/web/app/pages/delivery.css +1 -0
- stackvitals/web/app/pages/delivery.js +45 -0
- stackvitals/web/app/pages/deps.css +1 -0
- stackvitals/web/app/pages/deps.js +7 -0
- stackvitals/web/app/pages/explore.css +1 -0
- stackvitals/web/app/pages/explore.js +7 -0
- stackvitals/web/app/pages/health.css +1 -0
- stackvitals/web/app/pages/health.js +1 -0
- stackvitals/web/app/pages/help.css +1 -0
- stackvitals/web/app/pages/help.js +6 -0
- stackvitals/web/app/pages/insights.css +1 -0
- stackvitals/web/app/pages/insights.js +1 -0
- stackvitals/web/app/pages/investment.css +1 -0
- stackvitals/web/app/pages/investment.js +3 -0
- stackvitals/web/app/pages/overview.css +1 -0
- stackvitals/web/app/pages/overview.js +1 -0
- stackvitals/web/app/pages/portal.css +1 -0
- stackvitals/web/app/pages/portal.js +51 -0
- stackvitals/web/app/pages/prs.css +1 -0
- stackvitals/web/app/pages/prs.js +4 -0
- stackvitals/web/app/pages/settings.css +1 -0
- stackvitals/web/app/pages/settings.js +1 -0
- stackvitals/web/app/pages/setup.css +1 -0
- stackvitals/web/app/pages/setup.js +2 -0
- stackvitals/web/app/pages/standing.css +1 -0
- stackvitals/web/app/pages/standing.js +1 -0
- stackvitals/web/app/pages/welcome-sheet.js +1 -0
- stackvitals/web/app/pages/welcome.js +1 -0
- stackvitals/web/app/settings-workflow.json +1 -0
- stackvitals/web/index.html +34 -0
- stackvitals/web/site/index.html +108 -0
- stackvitals/web/site/integrations.html +15 -0
- stackvitals/web/site/setup.html +15 -0
- stackvitals/web/site/site.css +68 -0
- stackvitals-0.1.0.dist-info/METADATA +134 -0
- stackvitals-0.1.0.dist-info/RECORD +465 -0
- stackvitals-0.1.0.dist-info/WHEEL +4 -0
- stackvitals-0.1.0.dist-info/entry_points.txt +3 -0
- stackvitals-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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
|