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