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,454 @@
|
|
|
1
|
+
"""Supply chain from the repo's own dependency graph: the SBOM from GitHub's asynchronous report (the synchronous endpoint
|
|
2
|
+
closes on 13 November 2026), each package's licence from deps.dev (GitHub's SBOM mostly leaves licences blank), a licence
|
|
3
|
+
policy with decisions, licence drift between quarterly snapshots, and OpenSSF Scorecard drops for the projects behind the
|
|
4
|
+
packages. Everything is kept in deps/supply/ on the data branch, so the history outlives both services' caches."""
|
|
5
|
+
import fnmatch
|
|
6
|
+
import gzip
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import quote, unquote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from stackvitals.ci.api import APIError, OutOfBudget
|
|
17
|
+
from stackvitals.core.time import iso
|
|
18
|
+
|
|
19
|
+
SYSTEMS = {"npm": "NPM", "pypi": "PYPI", "maven": "MAVEN", "golang": "GO", "cargo": "CARGO", "gem": "RUBYGEMS", "nuget": "NUGET"}
|
|
20
|
+
ACTIONS = "ACTIONS" # workflow actions: not in deps.dev, and the project behind one is its own repo
|
|
21
|
+
DEPS_DEV = "https://api.deps.dev/v3"
|
|
22
|
+
SCORECARD = "https://api.scorecard.dev/projects"
|
|
23
|
+
UNSET = {"", "NOASSERTION", "NONE"}
|
|
24
|
+
POLLS = 30
|
|
25
|
+
LOOKUPS, SCORES = 200, 40 # the most deps.dev and Scorecard calls one run makes; the rest wait for the next run
|
|
26
|
+
LOW_SCORE, DROP = 4.0, 1.0
|
|
27
|
+
SCORE_EVERY = timedelta(days=7)
|
|
28
|
+
HISTORY = 12
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SupplyError(Exception):
|
|
32
|
+
"""A plain sentence for the job summary and the page."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SupplyRefused(SupplyError):
|
|
36
|
+
"""GitHub said no (the dependency graph is off, or no access): asking again soon won't change it."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _purl(text: str) -> tuple[str | None, str, str] | None:
|
|
40
|
+
"""(system, package name as deps.dev spells it, version) from a package URL; workflow actions are ACTIONS."""
|
|
41
|
+
found = re.match(r"pkg:([^/]+)/(.+?)@([^?#]+)", text or "")
|
|
42
|
+
if not found:
|
|
43
|
+
return None
|
|
44
|
+
kind, path, version = found.group(1).lower(), unquote(found.group(2)), unquote(found.group(3))
|
|
45
|
+
if kind == "githubactions":
|
|
46
|
+
return ACTIONS, path, version
|
|
47
|
+
if kind == "maven":
|
|
48
|
+
path = path.replace("/", ":", 1)
|
|
49
|
+
elif kind == "pypi":
|
|
50
|
+
path = re.sub(r"[-_.]+", "-", path).lower()
|
|
51
|
+
return SYSTEMS.get(kind), path, version
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_spdx(doc: dict) -> list[dict]:
|
|
55
|
+
"""The packages an SPDX document lists, with the licence GitHub gave each one when it gave one."""
|
|
56
|
+
doc = doc.get("sbom", doc)
|
|
57
|
+
# The repo itself is listed too (with a pkg:github purl): it is what the document describes, not a dependency.
|
|
58
|
+
itself = {r.get("relatedSpdxElement") for r in doc.get("relationships") or [] if r.get("relationshipType") == "DESCRIBES"}
|
|
59
|
+
out, seen = [], set()
|
|
60
|
+
for p in doc.get("packages") or []:
|
|
61
|
+
ref = next((r.get("referenceLocator") for r in p.get("externalRefs") or []
|
|
62
|
+
if r.get("referenceType") == "purl"), None)
|
|
63
|
+
parsed = _purl(ref) if ref and p.get("SPDXID") not in itself else None
|
|
64
|
+
if not parsed or ref in seen:
|
|
65
|
+
continue
|
|
66
|
+
seen.add(ref)
|
|
67
|
+
system, name, version = parsed
|
|
68
|
+
licence = next((x for x in (p.get("licenseConcluded"), p.get("licenseDeclared")) if x and x not in UNSET), None)
|
|
69
|
+
out.append({"purl": ref, "system": system, "name": name, "version": version, "licences": [licence] if licence else []})
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def sbom(api, polls: int = POLLS) -> list[dict]:
|
|
74
|
+
"""The default branch's SBOM: ask GitHub for a report, then wait until it is ready."""
|
|
75
|
+
try:
|
|
76
|
+
made = api._get(f"/repos/{api.repo}/dependency-graph/sbom/generate-report")
|
|
77
|
+
path = (made or {}).get("sbom_url", "").split("api.github.com", 1)[-1]
|
|
78
|
+
for _ in range(polls):
|
|
79
|
+
# 201 or 202 (with a "{}" body) while the report is being made; 200 once the redirect reached the file.
|
|
80
|
+
status, body = api.fetch(path)
|
|
81
|
+
if status == 200 and body:
|
|
82
|
+
return parse_spdx(json.loads(body))
|
|
83
|
+
api.sleep(1)
|
|
84
|
+
except APIError as exc:
|
|
85
|
+
# The status, not the start of the message: a long repo path once hid it.
|
|
86
|
+
status = re.search(r"HTTP \d{3}", str(exc))
|
|
87
|
+
raise SupplyRefused(f"GitHub would not make an SBOM for this repo ({status.group(0) if status else str(exc)[-80:]}): "
|
|
88
|
+
"turn on the dependency graph (Settings, Code security); the workflow needs contents: read.") from None
|
|
89
|
+
except (OutOfBudget, ValueError) as exc:
|
|
90
|
+
raise SupplyError(f"The SBOM could not be read this time ({type(exc).__name__}); the next run tries again.") from None
|
|
91
|
+
raise SupplyError(f"GitHub's SBOM report wasn't ready after {polls} tries; the next run tries again.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class LicenceCache:
|
|
95
|
+
"""deps.dev's answer per package version (licences and the project behind it), kept: a version never changes."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, root: Path):
|
|
98
|
+
self.path = Path(root) / "deps" / "supply" / "lookups.json.gz"
|
|
99
|
+
try:
|
|
100
|
+
self.data = json.loads(gzip.decompress(self.path.read_bytes()))
|
|
101
|
+
except (OSError, ValueError, EOFError):
|
|
102
|
+
self.data = {}
|
|
103
|
+
self.changed = False
|
|
104
|
+
|
|
105
|
+
def put(self, purl: str, value: dict) -> None:
|
|
106
|
+
self.data[purl] = value
|
|
107
|
+
self.changed = True
|
|
108
|
+
|
|
109
|
+
def save(self) -> None:
|
|
110
|
+
if self.changed:
|
|
111
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
self.path.write_bytes(gzip.compress(json.dumps(self.data, sort_keys=True, separators=(",", ":")).encode(), mtime=0))
|
|
113
|
+
self.changed = False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _lookup(p: dict, client: httpx.Client, cache: LicenceCache | None) -> dict | None:
|
|
117
|
+
if p.get("system") == ACTIONS:
|
|
118
|
+
return {"l": [], "p": "github.com/" + "/".join(p["name"].split("/")[:2])}
|
|
119
|
+
if cache is not None and p["purl"] in cache.data:
|
|
120
|
+
return cache.data[p["purl"]]
|
|
121
|
+
if not p.get("system"):
|
|
122
|
+
return None
|
|
123
|
+
url = f"{DEPS_DEV}/systems/{p['system']}/packages/{quote(p['name'], safe='')}/versions/{quote(p['version'], safe='')}"
|
|
124
|
+
try:
|
|
125
|
+
resp = client.get(url)
|
|
126
|
+
except httpx.HTTPError:
|
|
127
|
+
return None
|
|
128
|
+
if resp.status_code not in (200, 404):
|
|
129
|
+
return None
|
|
130
|
+
body = resp.json() if resp.status_code == 200 else {}
|
|
131
|
+
source = next((r.get("projectKey", {}).get("id") for r in body.get("relatedProjects") or []
|
|
132
|
+
if r.get("relationType") == "SOURCE_REPO" and r.get("projectKey", {}).get("id")), None)
|
|
133
|
+
value = {"l": [x for x in body.get("licenses") or [] if x and x not in UNSET], "p": source}
|
|
134
|
+
if cache is not None:
|
|
135
|
+
cache.put(p["purl"], value)
|
|
136
|
+
return value
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def licences(packages: list[dict], client: httpx.Client, cache: LicenceCache, limit: int = LOOKUPS) -> dict[str, list[str]]:
|
|
140
|
+
"""Each package's licences: deps.dev's, else GitHub's; looked up once per version and kept."""
|
|
141
|
+
out, asked = {}, 0
|
|
142
|
+
for p in packages:
|
|
143
|
+
known = p["purl"] in cache.data
|
|
144
|
+
if not known and (p.get("system") in (None, ACTIONS) or asked >= limit):
|
|
145
|
+
out[p["purl"]] = p.get("licences") or []
|
|
146
|
+
continue
|
|
147
|
+
asked += 0 if known else 1
|
|
148
|
+
found = _lookup(p, client, cache) or {}
|
|
149
|
+
out[p["purl"]] = found.get("l") or p.get("licences") or []
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _denied(expression: str, deny: list[str]) -> bool:
|
|
154
|
+
"""A choice ("A OR B") is fine when one choice is allowed; "A AND B" needs every part allowed; AND binds tighter than
|
|
155
|
+
OR and parentheses group, as SPDX says; "X WITH exception" is judged as X."""
|
|
156
|
+
tokens, at = re.findall(r"\(|\)|[^\s()]+", expression), 0
|
|
157
|
+
|
|
158
|
+
def peek() -> str:
|
|
159
|
+
return tokens[at].upper() if at < len(tokens) else ""
|
|
160
|
+
|
|
161
|
+
def either() -> bool: # allowed when any choice is allowed
|
|
162
|
+
nonlocal at
|
|
163
|
+
ok = both()
|
|
164
|
+
while peek() == "OR":
|
|
165
|
+
at += 1
|
|
166
|
+
ok = both() or ok
|
|
167
|
+
return ok
|
|
168
|
+
|
|
169
|
+
def both() -> bool: # allowed when every part is allowed
|
|
170
|
+
nonlocal at
|
|
171
|
+
ok = one()
|
|
172
|
+
while peek() == "AND":
|
|
173
|
+
at += 1
|
|
174
|
+
ok = one() and ok
|
|
175
|
+
return ok
|
|
176
|
+
|
|
177
|
+
def one() -> bool:
|
|
178
|
+
nonlocal at
|
|
179
|
+
if peek() == "(":
|
|
180
|
+
at += 1
|
|
181
|
+
ok = either()
|
|
182
|
+
at += peek() == ")"
|
|
183
|
+
return ok
|
|
184
|
+
name = tokens[at] if at < len(tokens) else ""
|
|
185
|
+
at += 1
|
|
186
|
+
if peek() == "WITH":
|
|
187
|
+
at += 2
|
|
188
|
+
return not any(fnmatch.fnmatchcase(name, pattern) for pattern in deny)
|
|
189
|
+
|
|
190
|
+
return not either()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# OSV's ecosystem names as deps.dev's systems, for a pull request's new packages.
|
|
194
|
+
OSV_SYSTEMS = {"npm": "NPM", "PyPI": "PYPI", "Maven": "MAVEN", "Go": "GO", "crates.io": "CARGO", "RubyGems": "RUBYGEMS", "NuGet": "NUGET"}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def pr_licences(new_packages: list, deny: list[str], client: httpx.Client, limit: int = 60) -> list[dict]:
|
|
198
|
+
"""The packages a pull request adds whose licence the repo's policy denies, looked up on deps.dev."""
|
|
199
|
+
out = []
|
|
200
|
+
for eco, name, version in new_packages[:limit]:
|
|
201
|
+
system = OSV_SYSTEMS.get(eco)
|
|
202
|
+
if not system:
|
|
203
|
+
continue
|
|
204
|
+
found = _lookup({"system": system, "name": name, "version": version, "purl": f"{eco}/{name}@{version}"}, client, None) or {}
|
|
205
|
+
expression = _joined(found.get("l") or [])
|
|
206
|
+
if expression and _denied(expression, deny):
|
|
207
|
+
out.append({"ecosystem": eco, "package": name, "version": version, "licence": expression})
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def policy(packages: list[dict], deny: list[str], accepted: set[str] = frozenset()) -> dict:
|
|
212
|
+
"""Packages whose licence the repo's policy denies (minus the ones a decision accepted), how many have no known
|
|
213
|
+
licence, and how many use each licence."""
|
|
214
|
+
denied, kept, by_licence, unknown = [], [], Counter(), 0
|
|
215
|
+
for p in packages:
|
|
216
|
+
if p.get("system") == ACTIONS:
|
|
217
|
+
continue # workflow actions run in CI and never ship with the product
|
|
218
|
+
if not p.get("licences"):
|
|
219
|
+
unknown += 1
|
|
220
|
+
continue
|
|
221
|
+
expression = _joined(p["licences"]) # several licences all apply, each kept whole in parentheses
|
|
222
|
+
by_licence[expression] += 1
|
|
223
|
+
if deny and _denied(expression, deny):
|
|
224
|
+
row = {"name": p["name"], "version": p["version"], "system": p.get("system"), "purl": p["purl"], "licence": expression}
|
|
225
|
+
(kept if p["name"] in accepted else denied).append(row)
|
|
226
|
+
return {"denied": denied, "accepted": kept, "unknown": unknown, "by_licence": dict(by_licence.most_common())}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _quarter(when: datetime) -> str:
|
|
230
|
+
return f"{when.year}-Q{(when.month - 1) // 3 + 1}"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class SupplyStore:
|
|
234
|
+
"""Quarterly licence snapshots and each project's Scorecard history, under deps/supply/."""
|
|
235
|
+
|
|
236
|
+
def __init__(self, root: Path):
|
|
237
|
+
self.dir = Path(root) / "deps" / "supply"
|
|
238
|
+
self.scores, self.asked = self._read("scorecards.json"), self._read("asked.json")
|
|
239
|
+
|
|
240
|
+
def _read(self, name: str) -> dict:
|
|
241
|
+
try:
|
|
242
|
+
return json.loads((self.dir / name).read_text(encoding="utf-8"))
|
|
243
|
+
except (OSError, ValueError):
|
|
244
|
+
return {}
|
|
245
|
+
|
|
246
|
+
def snapshot(self, packages: list[dict], when: datetime) -> None:
|
|
247
|
+
rows = sorted([p.get("system") or "", p["name"], p["version"], p.get("licences") or []] for p in packages)
|
|
248
|
+
path = self.dir / "snapshots" / f"{_quarter(when)}.json.gz"
|
|
249
|
+
data = gzip.compress(json.dumps(rows, separators=(",", ":")).encode(), mtime=0)
|
|
250
|
+
if not path.exists() or path.read_bytes() != data:
|
|
251
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
path.write_bytes(data)
|
|
253
|
+
|
|
254
|
+
def quarters(self) -> list[str]:
|
|
255
|
+
folder = self.dir / "snapshots"
|
|
256
|
+
return sorted(p.name.split(".")[0] for p in folder.glob("*.json.gz")) if folder.is_dir() else []
|
|
257
|
+
|
|
258
|
+
def load(self, quarter: str) -> list:
|
|
259
|
+
return json.loads(gzip.decompress((self.dir / "snapshots" / f"{quarter}.json.gz").read_bytes()))
|
|
260
|
+
|
|
261
|
+
def save(self) -> None:
|
|
262
|
+
for name, data in (("scorecards.json", self.scores), ("asked.json", self.asked)):
|
|
263
|
+
text = json.dumps(data, indent=1, sort_keys=True)
|
|
264
|
+
path = self.dir / name
|
|
265
|
+
if not path.exists() or path.read_text(encoding="utf-8") != text:
|
|
266
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
path.write_text(text, encoding="utf-8", newline="\n")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def drift(store: SupplyStore, now: datetime) -> list[dict]:
|
|
271
|
+
"""Packages whose licence changed between the last two quarterly snapshots (a version bump that also relicensed)."""
|
|
272
|
+
quarters = [q for q in store.quarters() if q <= _quarter(now)]
|
|
273
|
+
if len(quarters) < 2:
|
|
274
|
+
return []
|
|
275
|
+
before, after = quarters[-2], quarters[-1]
|
|
276
|
+
old, new = _newest(store.load(before)), _newest(store.load(after))
|
|
277
|
+
out = []
|
|
278
|
+
for (s, n), ls in new.items():
|
|
279
|
+
was, now_ = _joined(old.get((s, n))), _joined(ls)
|
|
280
|
+
if was and now_ and was != now_:
|
|
281
|
+
out.append({"name": n, "system": s or None, "from": was, "to": now_, "from_quarter": before, "to_quarter": after})
|
|
282
|
+
return sorted(out, key=lambda x: (x["system"] or "", x["name"]))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _version_key(version: str) -> list:
|
|
286
|
+
# Natural order (2.0 < 10.0), comparable whatever mix of digits and letters a version has.
|
|
287
|
+
return [(1, int(x), "") if x.isdigit() else (0, 0, x) for x in re.findall(r"\d+|\D+", version or "")]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _newest(rows: list) -> dict:
|
|
291
|
+
# Each package's licences at its newest version in a snapshot: a repo using two versions is not drift.
|
|
292
|
+
best = {}
|
|
293
|
+
for s, n, v, ls in rows:
|
|
294
|
+
if (s, n) not in best or _version_key(v) > _version_key(best[(s, n)][0]):
|
|
295
|
+
best[(s, n)] = (v, ls)
|
|
296
|
+
return {key: ls for key, (_v, ls) in best.items()}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _joined(licences: list | None) -> str | None:
|
|
300
|
+
if not licences:
|
|
301
|
+
return None
|
|
302
|
+
return licences[0] if len(licences) == 1 else " AND ".join(f"({x})" for x in licences)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
EVERY = timedelta(days=6) # once a week on the schedule; a manual run reads it at once
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def supply_step(ws, env: dict, now: datetime, make_api=None, client: httpx.Client | None = None) -> dict | None:
|
|
309
|
+
"""The supply chain page: the SBOM, licences, the repo's licence policy and decisions, drift and Scorecard drops,
|
|
310
|
+
published as docs/supply.json. Trouble (the dependency graph off, GitHub busy) is said and never fails the job."""
|
|
311
|
+
from stackvitals.core.config import parse
|
|
312
|
+
from stackvitals.core.decisions import Store
|
|
313
|
+
from stackvitals.core.store import published, write_if_changed
|
|
314
|
+
cfg = ws.cache.get("settings") or parse("")[0]
|
|
315
|
+
state_path = ws.path / "deps" / "supply" / "state.json"
|
|
316
|
+
try:
|
|
317
|
+
last = json.loads(state_path.read_text(encoding="utf-8")).get("last")
|
|
318
|
+
except (OSError, ValueError):
|
|
319
|
+
last = None
|
|
320
|
+
if env.get("GITHUB_EVENT_NAME") != "workflow_dispatch" and last and last > iso(now - EVERY):
|
|
321
|
+
return None
|
|
322
|
+
if make_api is None:
|
|
323
|
+
from stackvitals.ci.api import ActionsAPI as make_api
|
|
324
|
+
api = make_api(env.get("STACKVITALS_TOKEN") or env.get("GITHUB_TOKEN"), ws.repo, max_requests=60)
|
|
325
|
+
try:
|
|
326
|
+
packages = sbom(api)
|
|
327
|
+
except SupplyError as exc:
|
|
328
|
+
if isinstance(exc, SupplyRefused):
|
|
329
|
+
# Remembered, so a dependency graph that's off is asked about weekly, not on every 3-hourly run.
|
|
330
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
331
|
+
state_path.write_text(json.dumps({"last": iso(now), "error": str(exc)}), encoding="utf-8", newline="\n")
|
|
332
|
+
exc = f"{exc} Asked again next week, or now on a manual run."
|
|
333
|
+
ws.notes.append(f"Supply chain: {exc}")
|
|
334
|
+
return {"error": str(exc)}
|
|
335
|
+
own = client is None
|
|
336
|
+
client = client or httpx.Client(timeout=20, headers={"User-Agent": "stackvitals"})
|
|
337
|
+
cache, store = LicenceCache(ws.path), SupplyStore(ws.path)
|
|
338
|
+
outside = cfg["deps"].get("outside_lookups", True)
|
|
339
|
+
try:
|
|
340
|
+
# With outside lookups off, no dependency name leaves the repo: GitHub's licences and what is cached are kept.
|
|
341
|
+
found = licences(packages, client, cache, limit=LOOKUPS if outside else 0)
|
|
342
|
+
for p in packages:
|
|
343
|
+
p["licences"] = found.get(p["purl"]) or []
|
|
344
|
+
store.snapshot(packages, now)
|
|
345
|
+
state = Store(ws.path).state(now.date())
|
|
346
|
+
accepted = {key.split(":", 1)[1] for key, d in state.items() if key.startswith("package:") and d.get("verb") == "accept"}
|
|
347
|
+
deny = cfg["deps"]["licenses"]["deny"]
|
|
348
|
+
rules = policy(packages, deny, accepted)
|
|
349
|
+
cards = scorecards(packages, client, store, now, cache, lookups=0, limit=SCORES if outside else 0)
|
|
350
|
+
minimum = cfg["deps"]["policy"]["min_scorecard"]
|
|
351
|
+
cards["below_policy"] = below_policy(store.scores, cards.pop("names"), minimum, accepted)
|
|
352
|
+
finally:
|
|
353
|
+
# Whatever was looked up is kept even if a later step fails, so the next run starts further on.
|
|
354
|
+
cache.save()
|
|
355
|
+
store.save()
|
|
356
|
+
if own:
|
|
357
|
+
client.close()
|
|
358
|
+
reach = _reachability(env.get("STACKVITALS_GOVULNCHECK"))
|
|
359
|
+
# Runtimes (Node, Python, PHP, Ruby, Go) against their support dates: their flaws are not package advisories.
|
|
360
|
+
found_runtimes = None
|
|
361
|
+
if outside:
|
|
362
|
+
from stackvitals.deps.runtimes import runtimes
|
|
363
|
+
try:
|
|
364
|
+
with httpx.Client(timeout=20, headers={"User-Agent": "stackvitals"}) as eol:
|
|
365
|
+
found_runtimes = runtimes(api, api._get(f"/repos/{ws.repo}").get("default_branch") or "main", eol)
|
|
366
|
+
except Exception as exc: # noqa: BLE001 - the rest of the page still publishes
|
|
367
|
+
ws.notes.append(f"Supply chain: runtime versions not read ({type(exc).__name__}).")
|
|
368
|
+
out = {"generated": iso(now), "packages": len(packages), **({"reachability": reach} if reach else {}),
|
|
369
|
+
"systems": dict(Counter(p.get("system") or "OTHER" for p in packages).most_common()),
|
|
370
|
+
# Scorecard is shown as one input, never part of a package's verdict.
|
|
371
|
+
"policy": {"deny": deny, "min_scorecard": minimum}, "licences": rules, "drift": drift(store, now),
|
|
372
|
+
"scorecards": cards, "outside_lookups": outside, **({"runtimes": found_runtimes} if found_runtimes is not None else {})}
|
|
373
|
+
docs = ws.path / "docs"
|
|
374
|
+
docs.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
write_if_changed(docs / "supply.json", published(out))
|
|
376
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
377
|
+
state_path.write_text(json.dumps({"last": iso(now)}), encoding="utf-8", newline="\n")
|
|
378
|
+
return {"packages": len(packages), "denied": len(rules["denied"]), "drops": len(cards["drops"])}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _reachability(path: str | None) -> dict | None:
|
|
382
|
+
"""govulncheck's findings when the workflow ran it and passed its file (the govulncheck-json input)."""
|
|
383
|
+
if not path:
|
|
384
|
+
return None
|
|
385
|
+
from pathlib import Path
|
|
386
|
+
|
|
387
|
+
from stackvitals.deps.reach import read_govulncheck
|
|
388
|
+
try:
|
|
389
|
+
return read_govulncheck(Path(path).read_text(encoding="utf-8", errors="replace"))
|
|
390
|
+
except OSError:
|
|
391
|
+
return {"error": f"{path} couldn't be read; check the govulncheck step ran before StackVitals."}
|
|
392
|
+
except (ValueError, TypeError, KeyError, AttributeError):
|
|
393
|
+
return {"error": f"{path} isn't govulncheck's JSON output (run it with -json)."}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def below_policy(scores: dict, names: dict, minimum: float | None, accepted: set[str]) -> list[dict]:
|
|
397
|
+
"""Projects whose newest Scorecard is under the repo's min_scorecard, minus packages a decision accepted."""
|
|
398
|
+
if minimum is None:
|
|
399
|
+
return []
|
|
400
|
+
out = []
|
|
401
|
+
for project, history in sorted(scores.items()):
|
|
402
|
+
packages = sorted(n for n in names.get(project, ()) if n not in accepted)
|
|
403
|
+
if history and history[-1][1] < minimum and packages:
|
|
404
|
+
out.append({"project": project, "score": history[-1][1], "packages": packages})
|
|
405
|
+
return sorted(out, key=lambda x: x["score"])
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _score(client: httpx.Client, project: str) -> tuple[str, float] | None:
|
|
409
|
+
try:
|
|
410
|
+
resp = client.get(f"{SCORECARD}/{project}")
|
|
411
|
+
except httpx.HTTPError:
|
|
412
|
+
return None
|
|
413
|
+
if resp.status_code != 200:
|
|
414
|
+
return None
|
|
415
|
+
body = resp.json()
|
|
416
|
+
score = body.get("score")
|
|
417
|
+
return (str(body.get("date") or "")[:10], float(score)) if isinstance(score, (int, float)) else None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def scorecards(packages: list[dict], client: httpx.Client, store: SupplyStore, now: datetime, cache: LicenceCache | None = None,
|
|
421
|
+
limit: int = SCORES, lookups: int = LOOKUPS) -> dict:
|
|
422
|
+
"""The OpenSSF Scorecard of each project behind the packages, kept over time: drops of a point or more since the last
|
|
423
|
+
score, and projects scoring under 4. Only `lookups` new deps.dev calls (the step passes 0: the licence pass already
|
|
424
|
+
looked packages up), and the projects asked longest ago go first, so every project gets its turn."""
|
|
425
|
+
projects = {}
|
|
426
|
+
for p in packages:
|
|
427
|
+
if p.get("system") == ACTIONS or cache is not None and p["purl"] in cache.data:
|
|
428
|
+
found = _lookup(p, client, cache)
|
|
429
|
+
elif lookups > 0 and p.get("system"):
|
|
430
|
+
lookups -= 1
|
|
431
|
+
found = _lookup(p, client, cache)
|
|
432
|
+
else:
|
|
433
|
+
continue
|
|
434
|
+
if found and (found.get("p") or "").startswith("github.com/"):
|
|
435
|
+
projects.setdefault(found["p"], []).append(p["name"])
|
|
436
|
+
week_ago, today = iso(now - SCORE_EVERY)[:10], now.date().isoformat()
|
|
437
|
+
due = [p for p in projects if (store.asked.get(p) or "") <= week_ago]
|
|
438
|
+
for project in sorted(due, key=lambda p: (store.asked.get(p) or "", p))[:limit]:
|
|
439
|
+
store.asked[project] = today # 404s and unchanged answers count as asked, so they wait a week too
|
|
440
|
+
history = store.scores.get(project) or []
|
|
441
|
+
got = _score(client, project)
|
|
442
|
+
if got and (not history or history[-1][0] != got[0]):
|
|
443
|
+
store.scores[project] = (history + [list(got)])[-HISTORY:]
|
|
444
|
+
drops, low = [], []
|
|
445
|
+
for project, names in projects.items():
|
|
446
|
+
history = store.scores.get(project) or []
|
|
447
|
+
if len(history) >= 2 and history[-2][1] - history[-1][1] >= DROP:
|
|
448
|
+
drops.append({"project": project, "before": history[-2][1], "after": history[-1][1], "since": history[-2][0],
|
|
449
|
+
"packages": sorted(set(names))})
|
|
450
|
+
if history and history[-1][1] < LOW_SCORE:
|
|
451
|
+
low.append({"project": project, "score": history[-1][1], "packages": sorted(set(names))})
|
|
452
|
+
return {"checked": sum(1 for p in projects if store.scores.get(p)), "projects": len(projects),
|
|
453
|
+
"drops": sorted(drops, key=lambda x: x["after"] - x["before"]), "low": sorted(low, key=lambda x: x["score"]),
|
|
454
|
+
"names": {p: sorted(set(n)) for p, n in projects.items()}}
|