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,686 @@
|
|
|
1
|
+
"""Test connection: one cheap call per connector proves its key and the configured scope work, said as one plain sentence
|
|
2
|
+
with the next step when they don't. The app asks for it with a request ({"version": 1, "check": [...] or "all"}), a
|
|
3
|
+
comment asks with `/stackvitals check jira`, and `stackvitals connectors check` runs it locally. Results go under each
|
|
4
|
+
connector's entry in docs/connectors.json as "check"; a key is never sent anywhere but its own service, nor written."""
|
|
5
|
+
import argparse
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from stackvitals.connectors import ai_spend, copilot, discord, discourse, incident_io, jira, linear, pagerduty, slack, sonar
|
|
17
|
+
from stackvitals.core.time import iso
|
|
18
|
+
|
|
19
|
+
TIMEOUT = 10
|
|
20
|
+
TRANSPORT = None # tests swap in a fake transport
|
|
21
|
+
NAMES = {"jira": "Jira", "linear": "Linear", "pagerduty": "PagerDuty", "incident_io": "incident.io", "slack": "Slack",
|
|
22
|
+
"discord": "Discord", "discourse": "Discourse", "copilot": "Copilot", "webhook": "Chat webhooks", "gitlab": "GitLab",
|
|
23
|
+
"s3": "Bucket storage", "triage": "Model triage", "ai_spend": "AI spend", "sonar": "SonarQube"}
|
|
24
|
+
ALIASES = {"chat": "webhook", "webhooks": "webhook", "incident.io": "incident_io", "incidentio": "incident_io",
|
|
25
|
+
"bucket": "s3", "storage": "s3", "model": "triage", "spend": "ai_spend", "ai": "ai_spend", "sonarqube": "sonar",
|
|
26
|
+
"sonarcloud": "sonar"}
|
|
27
|
+
NAMED = {"discourse", "triage"} # the only "as" a public repo shows: a forum or a model provider names nobody
|
|
28
|
+
# Where each key lives: the secret to add, the workflow input that passes it, and the variable the CLI reads.
|
|
29
|
+
KEYS = {"jira": ("the repository secret JIRA_API_TOKEN", "jira-token", jira.TOKEN_ENV),
|
|
30
|
+
"linear": ("the repository secret LINEAR_API_KEY", "linear-token", linear.TOKEN_ENV),
|
|
31
|
+
"pagerduty": ("the repository secret PAGERDUTY_API_KEY", "pagerduty-token", pagerduty.TOKEN_ENV),
|
|
32
|
+
"incident_io": ("the repository secret INCIDENT_IO_API_KEY", "incident-io-token", incident_io.TOKEN_ENV),
|
|
33
|
+
"slack": ("the repository secret SLACK_BOT_TOKEN", "slack-token", slack.TOKEN_ENV),
|
|
34
|
+
"discord": ("the repository secret DISCORD_BOT_TOKEN", "discord-token", discord.TOKEN_ENV),
|
|
35
|
+
"copilot": ("the token you pass as copilot-token (an org App's token)", "copilot-token", copilot.TOKEN_ENV),
|
|
36
|
+
"webhook": ("the repository secret STACKVITALS_WEBHOOK", "webhook", "STACKVITALS_WEBHOOK"),
|
|
37
|
+
"gitlab": ("a masked CI/CD variable GITLAB_TOKEN (a project access token with api and write_repository)", None,
|
|
38
|
+
"GITLAB_TOKEN"),
|
|
39
|
+
"s3": ("the secrets you pass as s3-key-id and s3-secret", "s3-key-id and s3-secret",
|
|
40
|
+
"STACKVITALS_S3_KEY_ID and STACKVITALS_S3_SECRET"),
|
|
41
|
+
"triage": ("the secret you pass as triage-key (a free Gemini key works)", "triage-key", "STACKVITALS_TRIAGE_KEY"),
|
|
42
|
+
"ai_spend": ("one of the repository secrets ANTHROPIC_ADMIN_KEY, OPENAI_ADMIN_KEY or CURSOR_ADMIN_KEY",
|
|
43
|
+
"anthropic-admin-key, openai-admin-key or cursor-admin-key", ai_spend.KEYS["anthropic"]),
|
|
44
|
+
"sonar": ("the repository secret SONAR_TOKEN (only for a private project or your own SonarQube)", "sonar-token",
|
|
45
|
+
sonar.TOKEN_ENV)}
|
|
46
|
+
TEST_TITLE = "StackVitals connection test"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Fail(Exception):
|
|
50
|
+
def __init__(self, detail: str, fix: str):
|
|
51
|
+
super().__init__(detail)
|
|
52
|
+
self.detail, self.fix = detail, fix
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _missing(name: str) -> Fail:
|
|
56
|
+
where, given, var = KEYS[name]
|
|
57
|
+
passed = f" and pass it as {given} in the workflow" if given else ""
|
|
58
|
+
return Fail(f"No {NAMES[name]} key is set.", f"Add {where}{passed} (locally: {var}).")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _renew(name: str) -> str:
|
|
62
|
+
where, _given, var = KEYS[name]
|
|
63
|
+
return f"Create a new key, put it in {where} (locally: {var}), then test again."
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _key(env: dict, name: str) -> str:
|
|
67
|
+
key = (env.get(KEYS[name][2]) or "").strip()
|
|
68
|
+
if not key:
|
|
69
|
+
raise _missing(name)
|
|
70
|
+
return key
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _send(client, method: str, url: str, service: str, name: str, forbidden=None, missing=None, retry: bool = True,
|
|
74
|
+
**kw) -> httpx.Response:
|
|
75
|
+
"""One request, retried once on a network error or a 5xx (unless retry=False); the usual refusals become plain sentences."""
|
|
76
|
+
for attempt in ((1, 2) if retry else (2,)):
|
|
77
|
+
try:
|
|
78
|
+
resp = client.request(method, url, **kw)
|
|
79
|
+
except (httpx.HTTPError, httpx.InvalidURL) as exc:
|
|
80
|
+
if attempt == 1 and not isinstance(exc, httpx.InvalidURL):
|
|
81
|
+
continue
|
|
82
|
+
raise Fail(f"{service} could not be reached ({type(exc).__name__}).",
|
|
83
|
+
"Check the address and your network, then try again in a few minutes.") from None
|
|
84
|
+
if resp.status_code >= 500 and attempt == 1:
|
|
85
|
+
continue
|
|
86
|
+
break
|
|
87
|
+
code = resp.status_code
|
|
88
|
+
if code == 401:
|
|
89
|
+
raise Fail(f"{service} refused the key (401): it is wrong or expired.", _renew(name))
|
|
90
|
+
if code == 403:
|
|
91
|
+
what, fix = forbidden or ("the key lacks a permission or scope this needs", _renew(name))
|
|
92
|
+
raise Fail(f"{service} answered 403: {what}.", fix)
|
|
93
|
+
if code == 404:
|
|
94
|
+
what, fix = missing or ("the address wasn't found", f"Check the settings for {NAMES[name]}, then test again.")
|
|
95
|
+
raise Fail(f"{service} answered 404: {what}.", fix)
|
|
96
|
+
if code == 429:
|
|
97
|
+
raise Fail(f"{service}'s rate limit was reached (429).", "Wait a few minutes, then try again.")
|
|
98
|
+
if code >= 500:
|
|
99
|
+
raise Fail(f"{service} is having trouble ({code}).", "The service is down or busy; try again later.")
|
|
100
|
+
return resp
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _json(resp: httpx.Response, service: str, kinds=(dict,)):
|
|
104
|
+
try:
|
|
105
|
+
out = resp.json()
|
|
106
|
+
except ValueError:
|
|
107
|
+
out = None
|
|
108
|
+
if not isinstance(out, kinds):
|
|
109
|
+
raise Fail(f"{service} answered with a page that is not JSON ({resp.status_code}).",
|
|
110
|
+
"Check the address; if it is right, try again later.")
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _bad(resp: httpx.Response, service: str, fix: str = "Try again later; if it repeats, check the settings.") -> None:
|
|
115
|
+
if resp.status_code >= 300:
|
|
116
|
+
raise Fail(f"{service} answered {resp.status_code}.", fix)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _plural(n: int, word: str) -> str:
|
|
120
|
+
return f"{n} {word}" + ("" if n == 1 else "s")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _it(pub: bool, word: str, value) -> str:
|
|
124
|
+
# On a public repo a channel id, team key or bucket name stays off the data branch.
|
|
125
|
+
return f"one configured {word}" if pub else f"{word} {value}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _jira(cfg, env, client, now, pub=False):
|
|
129
|
+
key = _key(env, "jira")
|
|
130
|
+
c = cfg["connectors"]["jira"]
|
|
131
|
+
if not c["url"]:
|
|
132
|
+
raise Fail("Jira's site isn't set.", "Set url under [connectors.jira] in .github/stackvitals.toml to your Jira Cloud "
|
|
133
|
+
"site (https://<name>.atlassian.net).")
|
|
134
|
+
if not c["url"].strip().lower().startswith("https://"):
|
|
135
|
+
# The key would travel in the clear; it is never sent to a plain http address.
|
|
136
|
+
raise Fail("Jira's site address isn't https, so the key wasn't sent.", "Set url under [connectors.jira] to the "
|
|
137
|
+
"https address of your Jira Cloud site (https://<name>.atlassian.net).")
|
|
138
|
+
email = (env.get(jira.EMAIL_ENV) or "").strip() or c["email"]
|
|
139
|
+
if not email:
|
|
140
|
+
raise Fail("Jira needs the account's email beside the token.", "Add the repository secret JIRA_EMAIL and pass it as "
|
|
141
|
+
"jira-email (locally: STACKVITALS_JIRA_EMAIL), or set email under [connectors.jira].")
|
|
142
|
+
site = c["url"].rstrip("/")
|
|
143
|
+
headers = {"Authorization": "Basic " + base64.b64encode(f"{email}:{key}".encode()).decode(), "Accept": "application/json"}
|
|
144
|
+
wrong_site = ("there is no Jira Cloud API at that site", "Check url under [connectors.jira]: https://<name>.atlassian.net.")
|
|
145
|
+
me = _json(_send(client, "GET", f"{site}/rest/api/3/myself", "Jira", "jira", missing=wrong_site, headers=headers), "Jira")
|
|
146
|
+
scope = c["scope_jql"].strip()
|
|
147
|
+
where = "updated >= -30d ORDER BY updated DESC"
|
|
148
|
+
resp = _send(client, "GET", f"{site}/rest/api/3/search/jql", "Jira", "jira", headers=headers,
|
|
149
|
+
forbidden=("the account can't browse the projects in scope_jql", "Give the account Browse projects "
|
|
150
|
+
"permission on those projects, or change scope_jql under [connectors.jira]."),
|
|
151
|
+
params={"jql": f"({scope}) AND {where}" if scope else where, "maxResults": 1, "fields": "summary"})
|
|
152
|
+
if resp.status_code == 400:
|
|
153
|
+
try:
|
|
154
|
+
said = "; ".join((resp.json() or {}).get("errorMessages") or [])[:200]
|
|
155
|
+
except (ValueError, AttributeError):
|
|
156
|
+
said = ""
|
|
157
|
+
raise Fail(f"Jira couldn't run the scope query ({said or 'bad request'}).",
|
|
158
|
+
"Fix scope_jql under [connectors.jira] (JQL, like project = WEB).")
|
|
159
|
+
_bad(resp, "Jira")
|
|
160
|
+
issues = _json(resp, "Jira").get("issues") or []
|
|
161
|
+
if not issues:
|
|
162
|
+
return me.get("displayName"), ("Signed in, but the scope query found no issue updated in the last 30 days; check "
|
|
163
|
+
"scope_jql if that is unexpected.")
|
|
164
|
+
project = str(issues[0].get("key") or "").split("-")[0]
|
|
165
|
+
return me.get("displayName"), f"Signed in and read an issue from {_it(pub, 'project', project)} updated in the last 30 days."
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# The configured team keys are asked for by name, so a team past the first 100 is still found.
|
|
169
|
+
LINEAR_CHECK = ("query Check($keys: [String!]) { viewer { name } organization { name } "
|
|
170
|
+
"teams(first: 100) { nodes { key } pageInfo { hasNextPage } } "
|
|
171
|
+
"wanted: teams(first: 100, filter: {key: {in: $keys}}) { nodes { key } } }")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _linear(cfg, env, client, now, pub=False):
|
|
175
|
+
key = _key(env, "linear")
|
|
176
|
+
want = list(cfg["connectors"]["linear"]["teams"])
|
|
177
|
+
resp = _send(client, "POST", linear.URL, "Linear", "linear",
|
|
178
|
+
json={"query": LINEAR_CHECK, "variables": {"keys": [t.upper() for t in want]}},
|
|
179
|
+
headers={"Authorization": key, "Content-Type": "application/json"})
|
|
180
|
+
body = _json(resp, "Linear")
|
|
181
|
+
errors = body.get("errors") or []
|
|
182
|
+
if any((e.get("extensions") or {}).get("code") == "AUTHENTICATION_ERROR" for e in errors):
|
|
183
|
+
raise Fail("Linear refused the key: it is wrong or expired.", _renew("linear"))
|
|
184
|
+
if errors or resp.status_code != 200:
|
|
185
|
+
said = "; ".join(str(e.get("message") or "") for e in errors)[:200]
|
|
186
|
+
raise Fail(f"Linear couldn't answer ({said or resp.status_code}).", "Try again later.")
|
|
187
|
+
data = body.get("data") or {}
|
|
188
|
+
who = (data.get("viewer") or {}).get("name")
|
|
189
|
+
org = (data.get("organization") or {}).get("name")
|
|
190
|
+
teams = data.get("teams") or {}
|
|
191
|
+
keys = [str(n.get("key") or "") for n in teams.get("nodes") or []]
|
|
192
|
+
found = {str(n.get("key") or "").upper() for n in (data.get("wanted") or {}).get("nodes") or []} | {k.upper() for k in keys}
|
|
193
|
+
unseen = [t for t in want if t.upper() not in found]
|
|
194
|
+
if unseen:
|
|
195
|
+
if pub:
|
|
196
|
+
raise Fail(f"The key can't see {_plural(len(unseen), 'configured team')}.", "Check teams under "
|
|
197
|
+
"[connectors.linear] (team keys like ENG), or use a key from someone in that team.")
|
|
198
|
+
raise Fail(f"The key can't see team {', '.join(unseen)} (it sees {', '.join(keys[:6]) or 'no team'}).",
|
|
199
|
+
"Check teams under [connectors.linear] (team keys like ENG), or use a key from someone in that team.")
|
|
200
|
+
many = "100+ teams" if (teams.get("pageInfo") or {}).get("hasNextPage") else _plural(len(keys), "team")
|
|
201
|
+
shown = "" if pub else ", ".join((want or keys)[:3])
|
|
202
|
+
return (f"{who} ({org})" if who and org else who or org), \
|
|
203
|
+
f"Signed in and sees {many}" + (f", including {shown}." if shown else ".")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _pagerduty(cfg, env, client, now, pub=False):
|
|
207
|
+
key = _key(env, "pagerduty")
|
|
208
|
+
c = cfg["connectors"]["pagerduty"]
|
|
209
|
+
headers = {"Authorization": f"Token token={key}", "Accept": "application/vnd.pagerduty+json;version=2"}
|
|
210
|
+
base = pagerduty.URL.rsplit("/", 1)[0]
|
|
211
|
+
for sid in c["services"]:
|
|
212
|
+
fix = "Check services under [connectors.pagerduty]: the ids from each service's page address, like PABC123."
|
|
213
|
+
got = _send(client, "GET", f"{base}/services/{quote(sid, safe='')}", "PagerDuty", "pagerduty", headers=headers,
|
|
214
|
+
missing=(f"there is no {_it(pub, 'service', sid)} this key can see", fix))
|
|
215
|
+
_bad(got, f"PagerDuty, for {_it(pub, 'service', sid)},", fix)
|
|
216
|
+
params = [("limit", 1), *(("service_ids[]", s) for s in c["services"]), *(("urgencies[]", u) for u in c["urgencies"])]
|
|
217
|
+
resp = _send(client, "GET", pagerduty.URL, "PagerDuty", "pagerduty", headers=headers, params=params)
|
|
218
|
+
if resp.status_code == 400:
|
|
219
|
+
raise Fail("PagerDuty couldn't list incidents with these settings (400).",
|
|
220
|
+
"Check services and urgencies under [connectors.pagerduty].")
|
|
221
|
+
_bad(resp, "PagerDuty")
|
|
222
|
+
_json(resp, "PagerDuty")
|
|
223
|
+
n = len(c["services"])
|
|
224
|
+
return None, (f"Listed incidents for the {_plural(n, 'configured service')}." if n else
|
|
225
|
+
"Listed incidents; no services are set, so every service in the account counts.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _incident_io(cfg, env, client, now, pub=False):
|
|
229
|
+
key = _key(env, "incident_io")
|
|
230
|
+
resp = _send(client, "GET", incident_io.URL, "incident.io", "incident_io", params={"page_size": 1},
|
|
231
|
+
headers={"Authorization": f"Bearer {key}", "Accept": "application/json"},
|
|
232
|
+
forbidden=("the key lacks permission to view incidents", "Create a key with the \"View data\" permission and put it in "
|
|
233
|
+
"the repository secret INCIDENT_IO_API_KEY."))
|
|
234
|
+
_bad(resp, "incident.io")
|
|
235
|
+
_json(resp, "incident.io")
|
|
236
|
+
return None, "Listed incidents: the key can view them."
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
SLACK_REVOKED = {"invalid_auth", "not_authed", "account_inactive", "token_revoked", "token_expired"}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _slack_call(client, key: str, method: str, **params) -> dict:
|
|
243
|
+
resp = _send(client, "GET", f"{slack.API}/{method}", "Slack", "slack", params=params,
|
|
244
|
+
headers={"Authorization": f"Bearer {key}"})
|
|
245
|
+
body = _json(resp, "Slack")
|
|
246
|
+
err = body.get("error")
|
|
247
|
+
if not body.get("ok") and err in SLACK_REVOKED:
|
|
248
|
+
raise Fail(f"Slack refused the token ({err}): it is wrong or expired.", _renew("slack"))
|
|
249
|
+
if not body.get("ok") and err == "missing_scope":
|
|
250
|
+
need = body.get("needed") or "channels:history"
|
|
251
|
+
raise Fail(f"The Slack bot lacks the {need} scope.", f"Add the {need} scope to the Slack app, reinstall it, and "
|
|
252
|
+
"put the new token in the repository secret SLACK_BOT_TOKEN.")
|
|
253
|
+
return body
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _slack(cfg, env, client, now, pub=False):
|
|
257
|
+
key = _key(env, "slack")
|
|
258
|
+
me = _slack_call(client, key, "auth.test")
|
|
259
|
+
if not me.get("ok"):
|
|
260
|
+
raise Fail(f"Slack refused the token ({me.get('error')}).", _renew("slack"))
|
|
261
|
+
bot = me.get("user") or "the bot"
|
|
262
|
+
who = f"{me.get('user')} in {me.get('team')}" if me.get("user") and me.get("team") else me.get("team")
|
|
263
|
+
channels = cfg["connectors"]["slack"]["channels"]
|
|
264
|
+
if not channels:
|
|
265
|
+
raise Fail("The token works, but no channels are set.", "Add the support channels' ids (like C0123) under channels "
|
|
266
|
+
"in [connectors.slack].")
|
|
267
|
+
for ch in channels:
|
|
268
|
+
# The same call and scope the real pull uses (channels:history), so a pass means it will read.
|
|
269
|
+
got = _slack_call(client, key, "conversations.history", channel=ch, limit=1)
|
|
270
|
+
err = got.get("error")
|
|
271
|
+
if got.get("ok"):
|
|
272
|
+
continue
|
|
273
|
+
name = _it(pub, "channel", ch)
|
|
274
|
+
if err == "not_in_channel":
|
|
275
|
+
raise Fail(f"The bot isn't in {name}.", f"Invite it: open the channel in Slack and type /invite @{bot}.")
|
|
276
|
+
if err == "channel_not_found":
|
|
277
|
+
raise Fail(f"Slack has no {name} the bot can see (channel_not_found).",
|
|
278
|
+
f"Check channels under [connectors.slack] (ids like C0123); for a private channel, /invite @{bot} there.")
|
|
279
|
+
raise Fail(f"Slack refused reading {name} ({err}).", f"Check the bot's scopes (channels:history, "
|
|
280
|
+
f"groups:history for private channels) and /invite @{bot} to the channel.")
|
|
281
|
+
return who, f"Signed in and can read {_plural(len(channels), 'channel')}."
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _discord(cfg, env, client, now, pub=False):
|
|
285
|
+
key = _key(env, "discord")
|
|
286
|
+
headers = {"Authorization": f"Bot {key}"}
|
|
287
|
+
me = _json(_send(client, "GET", f"{discord.API}/users/@me", "Discord", "discord", headers=headers), "Discord")
|
|
288
|
+
channels = cfg["connectors"]["discord"]["channels"]
|
|
289
|
+
if not channels:
|
|
290
|
+
raise Fail("The bot token works, but no channels are set.", "Add the support channels' ids under channels in "
|
|
291
|
+
"[connectors.discord].")
|
|
292
|
+
where = ("Check channels under [connectors.discord] (turn on Developer Mode, right-click the channel, Copy Channel ID); "
|
|
293
|
+
"a forum or voice channel isn't read.")
|
|
294
|
+
for ch in channels:
|
|
295
|
+
name = _it(pub, "channel", ch)
|
|
296
|
+
resp = _send(client, "GET", f"{discord.API}/channels/{quote(ch, safe='')}/messages", "Discord", "discord",
|
|
297
|
+
headers=headers, params={"limit": 1},
|
|
298
|
+
forbidden=(f"the bot can't read {name}", "Give the bot View Channel and Read Message History in that "
|
|
299
|
+
"channel (its Permissions, or the bot's role)."),
|
|
300
|
+
missing=(f"there is no {name} the bot can see", where))
|
|
301
|
+
if resp.status_code == 400:
|
|
302
|
+
raise Fail(f"Discord answered 400 for {name}: not a text channel, or a bad channel id.", where)
|
|
303
|
+
_bad(resp, "Discord", where)
|
|
304
|
+
return me.get("username"), f"Signed in and can read {_plural(len(channels), 'channel')}."
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _discourse(cfg, env, client, now, pub=False):
|
|
308
|
+
url = (cfg["connectors"]["discourse"]["url"] or "").rstrip("/")
|
|
309
|
+
if not url:
|
|
310
|
+
raise Fail("No forum is set.", "Set url under [connectors.discourse] to the forum's address (https://...).")
|
|
311
|
+
resp = _send(client, "GET", f"{url}/latest.json", "The forum", "discourse", headers={"Accept": "application/json"},
|
|
312
|
+
missing=("there is no Discourse forum at that address", "Check url under [connectors.discourse]: the "
|
|
313
|
+
"forum's home address, like https://discuss.example.org."),
|
|
314
|
+
forbidden=("the forum doesn't let visitors read it", "StackVitals reads public forums only; a forum "
|
|
315
|
+
"that needs a login can't be read."))
|
|
316
|
+
_bad(resp, "The forum", "Check url under [connectors.discourse].")
|
|
317
|
+
topics = (_json(resp, "The forum").get("topic_list") or {}).get("topics") or []
|
|
318
|
+
return None, f"Read the forum's latest topics ({len(topics)} on the first page); no key needed."
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _webhook(cfg, env, client, now, pub=False):
|
|
322
|
+
from stackvitals.core import chat
|
|
323
|
+
url = (env.get("STACKVITALS_WEBHOOK") or "").strip()
|
|
324
|
+
teams = chat.team_hooks(env)
|
|
325
|
+
if not url and not teams:
|
|
326
|
+
raise _missing("webhook")
|
|
327
|
+
msg = chat.message(TEST_TITLE, ["A test from the Test connection button in StackVitals. Nothing needs doing; "
|
|
328
|
+
"you can delete this message."])
|
|
329
|
+
targets = ([("The webhook", url)] if url else []) + [("One team webhook" if pub else f"The {team} webhook", u)
|
|
330
|
+
for team, u in teams.items()]
|
|
331
|
+
fix = "Create a new incoming webhook and put it in the STACKVITALS_WEBHOOK secret (or the team's line in " \
|
|
332
|
+
"STACKVITALS_TEAM_WEBHOOKS)."
|
|
333
|
+
for label, hook in targets:
|
|
334
|
+
if not hook.startswith("https://"):
|
|
335
|
+
raise Fail(f"{label} isn't an https address.", fix)
|
|
336
|
+
# Never retried: a slow chat that did post would get the test message twice.
|
|
337
|
+
resp = _send(client, "POST", hook, label, "webhook", json=chat.payload(hook, msg), retry=False,
|
|
338
|
+
forbidden=("the chat refused the post", fix),
|
|
339
|
+
missing=("the webhook was deleted or its address is wrong", fix))
|
|
340
|
+
_bad(resp, label, fix)
|
|
341
|
+
shapes = {"slack": "Slack", "teams": "Teams", "discord": "Discord", "json": "JSON"}
|
|
342
|
+
who = ", ".join(([f"{shapes[chat.kind(url)]} webhook"] if url else []) +
|
|
343
|
+
([_plural(len(teams), "team webhook")] if teams else []))
|
|
344
|
+
return who, (f"Posted a labelled test message (\"{TEST_TITLE}\") to {_plural(len(targets), 'webhook')}; look for "
|
|
345
|
+
"it in the chat.")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
GITLAB_SCOPES = ("api", "write_repository") # notes and pipelines through the API; pushes to the data branch
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _gitlab(cfg, env, client, now, pub=False):
|
|
352
|
+
from stackvitals.ci import gitlab
|
|
353
|
+
key = _key(env, "gitlab")
|
|
354
|
+
project = (env.get("CI_PROJECT_PATH") or env.get("STACKVITALS_GITLAB_PROJECT") or "").strip()
|
|
355
|
+
if not project:
|
|
356
|
+
raise Fail("No GitLab project is named.", "Set STACKVITALS_GITLAB_PROJECT to group/project (inside GitLab CI it "
|
|
357
|
+
"comes from CI_PROJECT_PATH).")
|
|
358
|
+
base = (env.get("CI_API_V4_URL") or gitlab.API).rstrip("/")
|
|
359
|
+
scopes_fix = "Give the token the api and write_repository scopes (a new project access token), then update GITLAB_TOKEN."
|
|
360
|
+
headers = {"PRIVATE-TOKEN": key}
|
|
361
|
+
resp = _send(client, "GET", f"{base}/projects/{quote(project, safe='')}", "GitLab", "gitlab", headers=headers,
|
|
362
|
+
forbidden=("the token lacks a scope this needs", scopes_fix),
|
|
363
|
+
missing=(f"there is no {_it(pub, 'project', project)} this token can see", "Check the project path, and "
|
|
364
|
+
"that the token belongs to that project."))
|
|
365
|
+
_bad(resp, "GitLab")
|
|
366
|
+
body = _json(resp, "GitLab")
|
|
367
|
+
name = body.get("path_with_namespace") or project
|
|
368
|
+
shown = "the project" if pub else f"project {name}"
|
|
369
|
+
read = f"Read {shown} (default branch {body.get('default_branch') or 'unknown'})"
|
|
370
|
+
# Reading proves little: the job also writes notes and pushes, so the token's scopes are read too.
|
|
371
|
+
try:
|
|
372
|
+
me = _json(_send(client, "GET", f"{base}/personal_access_tokens/self", "GitLab", "gitlab", headers=headers), "GitLab")
|
|
373
|
+
except Fail:
|
|
374
|
+
me = None
|
|
375
|
+
scopes = me.get("scopes") if isinstance(me, dict) else None
|
|
376
|
+
if not isinstance(scopes, list):
|
|
377
|
+
# Some tokens (older GitLab, job tokens) can't read their own scopes; say what to confirm instead.
|
|
378
|
+
return (None if pub else name), (f"{read}; GitLab didn't show the token's scopes, so make sure it has api and "
|
|
379
|
+
"write_repository.")
|
|
380
|
+
lacking = [s for s in GITLAB_SCOPES if s not in scopes]
|
|
381
|
+
if lacking:
|
|
382
|
+
raise Fail(f"The token can read, but lacks the {' and '.join(lacking)} scope"
|
|
383
|
+
f"{'s' if len(lacking) > 1 else ''} it needs to write.", scopes_fix)
|
|
384
|
+
if me.get("active") is False:
|
|
385
|
+
raise Fail("GitLab says the token is no longer active.", _renew("gitlab"))
|
|
386
|
+
return (None if pub else name), f"{read}; the token has the api and write_repository scopes."
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _s3(cfg, env, client, now, pub=False):
|
|
390
|
+
from stackvitals.core.bucket import EMPTY, BucketError, BucketStore, _sha, sign
|
|
391
|
+
storage = (env.get("STACKVITALS_STORAGE") or "").strip()
|
|
392
|
+
if not storage.startswith("s3://"):
|
|
393
|
+
raise Fail("No bucket is set.", "Set the storage input to s3://bucket/prefix (locally: STACKVITALS_STORAGE).")
|
|
394
|
+
endpoint = (env.get("STACKVITALS_S3_ENDPOINT") or "").strip()
|
|
395
|
+
if not endpoint:
|
|
396
|
+
raise Fail("The bucket's endpoint isn't set.", "Set the s3-endpoint input to your bucket service's S3 endpoint "
|
|
397
|
+
"(locally: STACKVITALS_S3_ENDPOINT).")
|
|
398
|
+
kid, secret = (env.get("STACKVITALS_S3_KEY_ID") or "").strip(), (env.get("STACKVITALS_S3_SECRET") or "").strip()
|
|
399
|
+
if not kid or not secret:
|
|
400
|
+
raise _missing("s3")
|
|
401
|
+
try:
|
|
402
|
+
store = BucketStore(storage, Path("."), endpoint, kid, secret, env.get("STACKVITALS_S3_REGION") or "auto")
|
|
403
|
+
except BucketError as exc:
|
|
404
|
+
raise Fail(f"The storage address isn't right ({exc}).", "Set storage to s3://bucket/prefix.") from None
|
|
405
|
+
where = f"s3://{store.bucket}/{store.prefix}".rstrip("/")
|
|
406
|
+
spot = "the configured bucket and prefix" if pub else where
|
|
407
|
+
give = (f"the key lacks permission on {spot}", f"Give the key read, write, delete and list on {spot}, then test again.")
|
|
408
|
+
|
|
409
|
+
def call(method, url, body=b""):
|
|
410
|
+
# Signed at send time, not with the job's clock: S3 refuses a signature more than 15 minutes old.
|
|
411
|
+
headers = sign(method, url, {}, _sha(body) if body else EMPTY, kid, secret, store.region, datetime.now(timezone.utc))
|
|
412
|
+
return _send(client, method, url, "The bucket", "s3", content=body or None, headers=headers, forbidden=give,
|
|
413
|
+
missing=(f"there is no {_it(pub, 'bucket', store.bucket)} at that endpoint", "Check the bucket in "
|
|
414
|
+
"storage, s3-endpoint, and s3-region for AWS."))
|
|
415
|
+
listing = f"{store.endpoint}/{store.bucket}?list-type=2&max-keys=1" + (f"&prefix={store.prefix}/" if store.prefix else "")
|
|
416
|
+
_bad(call("GET", listing), "The bucket")
|
|
417
|
+
# Only after listing works: one tiny object under the prefix, removed right away.
|
|
418
|
+
probe = store._url(store._key(".stackvitals-check"))
|
|
419
|
+
_bad(call("PUT", probe, b"stackvitals connection check"), "The bucket")
|
|
420
|
+
left = (f"The test object .stackvitals-check was written but not deleted; it was left at {spot} and can be deleted "
|
|
421
|
+
"by hand")
|
|
422
|
+
try:
|
|
423
|
+
gone = call("DELETE", probe)
|
|
424
|
+
except Fail as f:
|
|
425
|
+
raise Fail(f"{left} ({f.detail.rstrip('.')}).", f"Give the key delete on {spot}, then delete .stackvitals-check "
|
|
426
|
+
"there.") from None
|
|
427
|
+
if gone.status_code >= 300:
|
|
428
|
+
raise Fail(f"{left} ({gone.status_code}).", f"Give the key delete on {spot}, then delete .stackvitals-check there.")
|
|
429
|
+
return (None if pub else where), "Listed the prefix, then wrote and deleted a small test object (.stackvitals-check)."
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _triage(cfg, env, client, now, pub=False):
|
|
433
|
+
from stackvitals.ci.triage import PROVIDERS
|
|
434
|
+
key = _key(env, "triage")
|
|
435
|
+
t = (cfg.get("ci") or {}).get("triage") or {}
|
|
436
|
+
provider = t.get("provider") or "off"
|
|
437
|
+
if provider not in PROVIDERS:
|
|
438
|
+
raise Fail("Model triage is off, so the key isn't used.", "Set provider under [ci.triage] (gemini for Google AI "
|
|
439
|
+
"Studio's free tier).")
|
|
440
|
+
url = (t.get("url") or PROVIDERS[provider]["url"]).rstrip("/")
|
|
441
|
+
model = t.get("model") or PROVIDERS[provider]["model"]
|
|
442
|
+
headers = {"Authorization": f"Bearer {key}"}
|
|
443
|
+
if provider == "anthropic":
|
|
444
|
+
headers.update({"x-api-key": key, "anthropic-version": "2023-06-01"})
|
|
445
|
+
resp = _send(client, "GET", f"{url}/models", provider.capitalize(), "triage", headers=headers,
|
|
446
|
+
missing=("the endpoint has no models list", "Check url under [ci.triage]."))
|
|
447
|
+
if resp.status_code == 400:
|
|
448
|
+
raise Fail(f"{provider.capitalize()} refused the key (400): it is wrong or expired.", _renew("triage"))
|
|
449
|
+
_bad(resp, provider.capitalize())
|
|
450
|
+
ids = [str(m.get("id") or "") for m in _json(resp, provider.capitalize()).get("data") or [] if isinstance(m, dict)]
|
|
451
|
+
if any(i == model or i.endswith("/" + model) for i in ids):
|
|
452
|
+
return provider, f"The key works and {model} is available."
|
|
453
|
+
return provider, f"The key works, but {model} isn't in the provider's model list; check model under [ci.triage]."
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _copilot(cfg, env, client, now, pub=False):
|
|
457
|
+
key = _key(env, "copilot")
|
|
458
|
+
org = cfg["connectors"]["copilot"]["org"] or (env.get("GITHUB_REPOSITORY") or "").partition("/")[0]
|
|
459
|
+
if not org:
|
|
460
|
+
raise Fail("No organization is named.", "Set org under [connectors.copilot].")
|
|
461
|
+
day = (now.date() - timedelta(days=copilot.LAG)).isoformat()
|
|
462
|
+
resp = _send(client, "GET", f"{copilot.API}/orgs/{quote(org, safe='')}/copilot/metrics/reports/organization-1-day",
|
|
463
|
+
"GitHub", "copilot", params={"day": day},
|
|
464
|
+
headers={"Authorization": f"Bearer {key}", "Accept": "application/vnd.github+json",
|
|
465
|
+
"X-GitHub-Api-Version": copilot.VERSION},
|
|
466
|
+
forbidden=("the token lacks the \"Organization Copilot metrics: read\" permission", "Make the token from an org App "
|
|
467
|
+
"(or a fine-grained token) with Organization Copilot metrics: read, set up by an org owner."),
|
|
468
|
+
missing=(f"there are no Copilot usage reports for {org}", "Check org under [connectors.copilot], and that "
|
|
469
|
+
"Copilot usage metrics are turned on for the organization."))
|
|
470
|
+
_bad(resp, "GitHub")
|
|
471
|
+
if resp.status_code == 204:
|
|
472
|
+
return org, f"The token can read {org}'s Copilot usage reports; GitHub has none for {day} yet."
|
|
473
|
+
return org, f"The token can read {org}'s Copilot usage reports (the one for {day} is ready)."
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _ai_spend(cfg, env, client, now, pub=False):
|
|
477
|
+
"""One day of each provider's cost report (one page of Cursor's spend) with each admin key set."""
|
|
478
|
+
keys = {name: (env.get(var) or "").strip() for name, var in ai_spend.KEYS.items()}
|
|
479
|
+
if not any(keys.values()):
|
|
480
|
+
raise _missing("ai_spend")
|
|
481
|
+
day = (now - timedelta(days=1)).strftime("%Y-%m-%dT00:00:00Z")
|
|
482
|
+
read = []
|
|
483
|
+
if keys["anthropic"]:
|
|
484
|
+
_bad(_send(client, "GET", ai_spend.ANTHROPIC, "Anthropic", "ai_spend", params={"starting_at": day, "limit": 1},
|
|
485
|
+
headers={"x-api-key": keys["anthropic"], "anthropic-version": "2023-06-01"},
|
|
486
|
+
forbidden=("the key isn't an Admin API key", "Create an Admin API key (sk-ant-admin...) in the Claude "
|
|
487
|
+
"Console's organization settings; workspace keys can't read costs.")), "Anthropic")
|
|
488
|
+
read.append("Anthropic's cost report")
|
|
489
|
+
if keys["openai"]:
|
|
490
|
+
_bad(_send(client, "GET", ai_spend.OPENAI, "OpenAI", "ai_spend",
|
|
491
|
+
params={"start_time": int((now - timedelta(days=1)).timestamp()), "limit": 1},
|
|
492
|
+
headers={"Authorization": f"Bearer {keys['openai']}"},
|
|
493
|
+
forbidden=("the key isn't an admin key", "Create an admin key in OpenAI's organization settings.")), "OpenAI")
|
|
494
|
+
read.append("OpenAI's costs")
|
|
495
|
+
if keys["cursor"]:
|
|
496
|
+
_bad(_send(client, "POST", f"{ai_spend.CURSOR}/teams/spend", "Cursor", "ai_spend", json={"pageSize": 1},
|
|
497
|
+
auth=(keys["cursor"], ""),
|
|
498
|
+
forbidden=("the key isn't a Cursor admin API key", "Create one in Cursor's team settings (admins only).")),
|
|
499
|
+
"Cursor")
|
|
500
|
+
read.append("Cursor's team spend")
|
|
501
|
+
return None, "Read " + ", ".join(read) + "."
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _sonar(cfg, env, client, now, pub=False):
|
|
505
|
+
"""The project's quality gate, read once (public SonarCloud projects need no key)."""
|
|
506
|
+
c = cfg["connectors"]["sonar"]
|
|
507
|
+
if not c.get("project"):
|
|
508
|
+
raise Fail("No Sonar project is set.", "Set project under [connectors.sonar], like myorg_myrepo.")
|
|
509
|
+
token = (env.get(sonar.TOKEN_ENV) or "").strip()
|
|
510
|
+
resp = _send(client, "GET", f"{(c.get('url') or 'https://sonarcloud.io').rstrip('/')}/api/qualitygates/project_status",
|
|
511
|
+
"Sonar", "sonar", params={"projectKey": c["project"]}, headers={"Authorization": f"Bearer {token}"} if token else {},
|
|
512
|
+
forbidden=("Sonar refused the request", "A private project needs the SONAR_TOKEN secret."))
|
|
513
|
+
_bad(resp, "Sonar", "Check project under [connectors.sonar]; the key is shown in the project's Information page.")
|
|
514
|
+
gate = (resp.json().get("projectStatus") or {}).get("status")
|
|
515
|
+
return None, f"Read {c['project']}'s quality gate ({gate or 'none set'})."
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
CHECKS = {"jira": _jira, "linear": _linear, "pagerduty": _pagerduty, "incident_io": _incident_io, "slack": _slack,
|
|
519
|
+
"discord": _discord, "discourse": _discourse, "copilot": _copilot, "webhook": _webhook, "gitlab": _gitlab,
|
|
520
|
+
"s3": _s3, "triage": _triage, "ai_spend": _ai_spend, "sonar": _sonar}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _scrub(out: dict, env: dict) -> dict:
|
|
524
|
+
# A last guard: no key or webhook address from the environment survives in what is said.
|
|
525
|
+
secrets = [v.strip() for k, v in env.items() if v and len(v.strip()) >= 8 and any(
|
|
526
|
+
w in k for w in ("TOKEN", "KEY", "SECRET", "WEBHOOK", "EMAIL"))]
|
|
527
|
+
for field in ("as", "detail", "fix"):
|
|
528
|
+
if isinstance(out.get(field), str):
|
|
529
|
+
for s in secrets:
|
|
530
|
+
out[field] = out[field].replace(s, "***")
|
|
531
|
+
return out
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def check(name: str, cfg: dict, env: dict, client: httpx.Client | None = None, now: datetime | None = None,
|
|
535
|
+
public: bool = False) -> dict:
|
|
536
|
+
"""{"ok", "as", "detail", "checked_at", "fix"} for one connector; a failure of any kind is a failed check, never an error."""
|
|
537
|
+
now = now or datetime.now(timezone.utc)
|
|
538
|
+
own = client is None
|
|
539
|
+
client = client or httpx.Client(timeout=TIMEOUT, transport=TRANSPORT, follow_redirects=False)
|
|
540
|
+
try:
|
|
541
|
+
who, detail = CHECKS[name](cfg, env, client, now, public)
|
|
542
|
+
out = {"ok": True, "as": who or None, "detail": detail, "fix": None}
|
|
543
|
+
except Fail as f:
|
|
544
|
+
out = {"ok": False, "as": None, "detail": f.detail, "fix": f.fix}
|
|
545
|
+
except Exception as exc: # noqa: BLE001 - a bug in one check must never stop the run or the other checks
|
|
546
|
+
out = {"ok": False, "as": None, "detail": f"The check stopped on an unexpected {type(exc).__name__}.",
|
|
547
|
+
"fix": "Try again; if it repeats, open an issue on StackVitals with this message."}
|
|
548
|
+
finally:
|
|
549
|
+
if own:
|
|
550
|
+
client.close()
|
|
551
|
+
out = _scrub(out, env)
|
|
552
|
+
return {"ok": out["ok"], "as": out["as"], "detail": out["detail"], "checked_at": iso(now), "fix": out["fix"]}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def configured(name: str, env: dict, cfg: dict) -> bool:
|
|
556
|
+
"""Whether a connector has anything set up, so "all" tests it."""
|
|
557
|
+
if name == "discourse":
|
|
558
|
+
return discourse.configured(env, cfg)
|
|
559
|
+
if name == "webhook":
|
|
560
|
+
return bool((env.get("STACKVITALS_WEBHOOK") or "").strip() or (env.get("STACKVITALS_TEAM_WEBHOOKS") or "").strip())
|
|
561
|
+
if name == "s3":
|
|
562
|
+
return (env.get("STACKVITALS_STORAGE") or "").strip().startswith("s3://")
|
|
563
|
+
if name == "ai_spend":
|
|
564
|
+
return ai_spend.configured(env, cfg)
|
|
565
|
+
if name == "sonar":
|
|
566
|
+
return sonar.configured(env, cfg)
|
|
567
|
+
if name == "triage":
|
|
568
|
+
return bool((env.get("STACKVITALS_TRIAGE_KEY") or "").strip()) or \
|
|
569
|
+
(((cfg.get("ci") or {}).get("triage") or {}).get("provider") or "off") != "off"
|
|
570
|
+
return bool((env.get(KEYS[name][2]) or "").strip())
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def resolve(asked, cfg: dict, env: dict) -> list[str]:
|
|
574
|
+
"""The connectors to test: "all" means every one set up; names may use the app's ids (chat) or aliases."""
|
|
575
|
+
if asked == "all":
|
|
576
|
+
return [n for n in CHECKS if configured(n, env, cfg)]
|
|
577
|
+
out = []
|
|
578
|
+
for raw in asked:
|
|
579
|
+
name = ALIASES.get(str(raw).strip().lower(), str(raw).strip().lower())
|
|
580
|
+
if name not in CHECKS:
|
|
581
|
+
raise ValueError(f"Unknown connector {raw!r}; known: {', '.join(CHECKS)} (or all).")
|
|
582
|
+
if name not in out:
|
|
583
|
+
out.append(name)
|
|
584
|
+
return out
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def run(names: list[str], cfg: dict, env: dict, now: datetime | None = None, client: httpx.Client | None = None,
|
|
588
|
+
public: bool = False) -> dict:
|
|
589
|
+
return {n: check(n, cfg, env, client, now, public) for n in names}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def shown(results: dict, public: bool) -> dict:
|
|
593
|
+
"""What may be published: on a public repo "as" (an account, bot, project or bucket) is left out, but for the forum and model."""
|
|
594
|
+
return {n: {**r, "as": None} if public and n not in NAMED else r for n, r in results.items()}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def save(root: Path, results: dict, public: bool) -> None:
|
|
598
|
+
"""Each result under its connector's entry in docs/connectors.json as "check", every other field kept."""
|
|
599
|
+
from stackvitals.core.store import published, write_if_changed
|
|
600
|
+
path = Path(root) / "docs" / "connectors.json"
|
|
601
|
+
try:
|
|
602
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
603
|
+
except (OSError, ValueError):
|
|
604
|
+
doc = {}
|
|
605
|
+
conn = dict(doc.get("connectors") or {})
|
|
606
|
+
for name, r in shown(results, public).items():
|
|
607
|
+
conn[name] = {**(conn.get(name) or {"name": NAMES[name]}), "check": r}
|
|
608
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
609
|
+
write_if_changed(path, published({**{k: v for k, v in doc.items() if k not in ("schema", "connectors")}, "connectors": conn}))
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def line(name: str, r: dict) -> str:
|
|
613
|
+
if r["ok"]:
|
|
614
|
+
return f"{NAMES[name]}: working" + (f", as {r['as']}" if r.get("as") else "") + f". {r['detail']}"
|
|
615
|
+
return f"{NAMES[name]}: not working. {r['detail']} Fix: {r['fix']}"
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _env_file(path: Path) -> dict:
|
|
619
|
+
"""KEY=VALUE lines of a .env file (comments, `export` and quotes allowed)."""
|
|
620
|
+
out = {}
|
|
621
|
+
try:
|
|
622
|
+
text = path.read_text(encoding="utf-8")
|
|
623
|
+
except OSError:
|
|
624
|
+
return out
|
|
625
|
+
for raw in text.splitlines():
|
|
626
|
+
s = raw.strip()
|
|
627
|
+
if not s or s.startswith("#") or "=" not in s:
|
|
628
|
+
continue
|
|
629
|
+
k, _, v = s.removeprefix("export ").partition("=")
|
|
630
|
+
v = v.strip()
|
|
631
|
+
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
|
632
|
+
v = v[1:-1]
|
|
633
|
+
elif " #" in v:
|
|
634
|
+
# An unquoted " # note" after the value is a comment, as in other .env readers.
|
|
635
|
+
v = v.split(" #", 1)[0].rstrip()
|
|
636
|
+
out[k.strip()] = v
|
|
637
|
+
return out
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# The repository secret each key is stored as; a local .env written with those names is read the same way.
|
|
641
|
+
SECRET_NAMES = {jira.TOKEN_ENV: "JIRA_API_TOKEN", jira.EMAIL_ENV: "JIRA_EMAIL",linear.TOKEN_ENV: "LINEAR_API_KEY", pagerduty.TOKEN_ENV: "PAGERDUTY_API_KEY",
|
|
642
|
+
incident_io.TOKEN_ENV: "INCIDENT_IO_API_KEY", slack.TOKEN_ENV: "SLACK_BOT_TOKEN",
|
|
643
|
+
discord.TOKEN_ENV: "DISCORD_BOT_TOKEN", sonar.TOKEN_ENV: "SONAR_TOKEN",
|
|
644
|
+
**{env: env.removeprefix("STACKVITALS_") for env in ai_spend.KEYS.values()}}
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def load_env(path: Path, environ) -> dict:
|
|
648
|
+
"""The .env file under the environment, with keys saved under their repository secret names filled in."""
|
|
649
|
+
env = {**_env_file(path), **environ}
|
|
650
|
+
for name, secret in SECRET_NAMES.items():
|
|
651
|
+
if not (env.get(name) or "").strip() and (env.get(secret) or "").strip():
|
|
652
|
+
env[name] = env[secret]
|
|
653
|
+
return env
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def main(argv: list[str] | None = None) -> int:
|
|
657
|
+
from stackvitals.core.config import FILE, parse
|
|
658
|
+
parser = argparse.ArgumentParser(prog="stackvitals connectors", description="Connectors to outside services.")
|
|
659
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
660
|
+
p = sub.add_parser("check", help="Test each connector's key with one cheap call (a chat webhook gets one test message)")
|
|
661
|
+
p.add_argument("names", nargs="*", help=f"connectors to test: {', '.join(CHECKS)} (default: every one set up)")
|
|
662
|
+
p.add_argument("--all", action="store_true", help="test every connector that has a key or settings")
|
|
663
|
+
p.add_argument("--json", action="store_true", help="print the results as JSON")
|
|
664
|
+
p.add_argument("--config", default=FILE, help=f"the settings file (default {FILE})")
|
|
665
|
+
p.add_argument("--env-file", default=".env", help="a KEY=VALUE file read for keys not in the environment (default .env)")
|
|
666
|
+
args = parser.parse_args(argv)
|
|
667
|
+
env = load_env(Path(args.env_file), os.environ)
|
|
668
|
+
try:
|
|
669
|
+
cfg = parse(Path(args.config).read_text(encoding="utf-8"))[0]
|
|
670
|
+
except OSError:
|
|
671
|
+
cfg = parse("")[0]
|
|
672
|
+
try:
|
|
673
|
+
names = resolve("all" if args.all or not args.names else args.names, cfg, env)
|
|
674
|
+
except ValueError as exc:
|
|
675
|
+
print(exc, file=sys.stderr)
|
|
676
|
+
return 2
|
|
677
|
+
if not names:
|
|
678
|
+
print("Nothing is set up to test: no connector key in the environment and none in the settings file.")
|
|
679
|
+
return 0
|
|
680
|
+
results = run(names, cfg, env)
|
|
681
|
+
if args.json:
|
|
682
|
+
print(json.dumps(results, indent=1))
|
|
683
|
+
else:
|
|
684
|
+
for name, r in results.items():
|
|
685
|
+
print(line(name, r))
|
|
686
|
+
return 0 if all(r["ok"] for r in results.values()) else 1
|