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
stackvitals/ci/kinds.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""What kind of flake a failure looks like (the environment: infrastructure, network, concurrency, timing; or the test's code:
|
|
2
|
+
resource leak, async wait, time, randomness, floating point, shared state, io, ordering), so people know where
|
|
3
|
+
to look. Measured against labelled causes in bench/accuracy/flaky/kinds.py."""
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
# Checked in this order.
|
|
7
|
+
KINDS = (
|
|
8
|
+
# The runner, a cache or artifact store, or a service the job starts on its own machine.
|
|
9
|
+
("infrastructure", re.compile(r"(?i)(ServerBusy|No space left on device|runner has received a shutdown signal|"
|
|
10
|
+
r"lost communication with the server|Cannot connect to the Docker daemon|"
|
|
11
|
+
r"failed to connect to the docker API|hosted runner encountered an error|out of memory|"
|
|
12
|
+
r"OOMKilled|exit code 137|Cannot allocate memory|heap out of memory|FinalizeArtifact|"
|
|
13
|
+
r"upload of artifact|artifact (upload|download) failed|"
|
|
14
|
+
r"(127\.0\.0\.1|localhost)\S{0,8}.{0,60}connection refused)")),
|
|
15
|
+
# ENOTFOUND matched case-sensitively: "FileNotFoundError" holds it in any case.
|
|
16
|
+
("network", re.compile(r"(?i)((?-i:\bENOTFOUND\b)|EAI_AGAIN|ECONNRESET|ECONNREFUSED|ETIMEDOUT|socket hang up|Could not resolve host|"
|
|
17
|
+
r"Temporary failure in name resolution|Name or service not known|Failed to fetch|"
|
|
18
|
+
r"Failed to download(?! and build)|\b50[234] (Service Unavailable|Bad Gateway|Gateway Time-?out)|"
|
|
19
|
+
r"HTTP (error )?5\d\d|status code 5\d\d|returned HTTP 5\d\d|TLS handshake timeout|i/o timeout|"
|
|
20
|
+
r"Connection (timed out|reset|refused)|Unable to make request|toomanyrequests|Too Many Requests|"
|
|
21
|
+
r"rate limit|quota exceeded|Service Unavailable|net/http: request canceled|Client\.Timeout exceeded|"
|
|
22
|
+
r"Error response from daemon|download has timed out)")),
|
|
23
|
+
("concurrency", re.compile(r"(?i)(DATA RACE|data race|deadlock|ConcurrentModificationException|race condition|"
|
|
24
|
+
r"concurrent map (writes|read and map write)|already borrowed)")),
|
|
25
|
+
("timing", re.compile(r"(?i)(timed? ?out (after|in|waiting)|timeout of \d+ ?ms exceeded|exceeded (the )?timeout|"
|
|
26
|
+
r"test timed out|deadline exceeded|TimeoutError|Timeout - Async callback|waiting for .{0,80} timed out|"
|
|
27
|
+
r"Expected condition failed: waiting for|Matcher did not succeed in time|"
|
|
28
|
+
r"exceeded the maximum execution time|has timed out)")),
|
|
29
|
+
# Causes in the test's own code (Datadog's categories), checked after the environment's.
|
|
30
|
+
("resource leak", re.compile(r"(?i)(Too many open files|EMFILE|ResourceWarning|unclosed (file|socket|transport)|"
|
|
31
|
+
r"address already in use|EADDRINUSE|port \d+ is already (in use|allocated)|leaked? (handle|connection))")),
|
|
32
|
+
("async wait", re.compile(r"(?i)(not wrapped in act\(|Unable to find (an )?element|StaleElementReference|"
|
|
33
|
+
r"ElementNotInteractable|detached from (the )?DOM|element is not attached|waitFor(Element)? .{0,40}failed|"
|
|
34
|
+
r"Promise rejection was handled asynchronously|unhandled promise rejection)")),
|
|
35
|
+
("time", re.compile(r"(?i)(time ?zone|\bDST\b|daylight saving|leap (year|second)|ZoneInfo|ZonedDateTime|"
|
|
36
|
+
r"datetime\.(now|utcnow)|LocalDate|Clock skew|month end|end of (the )?month)")),
|
|
37
|
+
("randomness", re.compile(r"(?i)(Falsifying example|random seed|seed[=: ]+\d+|Math\.random|randomly ordered|"
|
|
38
|
+
r"--randomly-seed|--seed=\d+)")),
|
|
39
|
+
("floating point", re.compile(r"(?i)(\d\.\d{12,}|not almost equal|assertAlmostEqual|approx\(|ULP|"
|
|
40
|
+
r"floating[- ]point|toBeCloseTo|within tolerance|rel_tol|abs_tol)")),
|
|
41
|
+
("shared state", re.compile(r"(?i)(already registered|already exists|duplicate key|UNIQUE constraint failed|"
|
|
42
|
+
r"IntegrityError|state from (a |another )?(previous|other) test|test pollution|singleton)")),
|
|
43
|
+
("io", re.compile(r"(?i)(FileNotFoundError|ENOENT|No such file or directory|Permission denied|EACCES|"
|
|
44
|
+
r"Directory not empty|ENOTEMPTY|file is being used by another process|EBUSY)")),
|
|
45
|
+
("ordering", re.compile(r"(?i)(Items in the (first|second) (set|list)|Element counts were not equal|in any order|"
|
|
46
|
+
r"Lists differ|expected order|unordered|sort(ed)? order)")),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def kind_of(text: str | None) -> str | None:
|
|
51
|
+
"""The first kind whose signs the text shows, checked in order; None when it shows none."""
|
|
52
|
+
return next((name for name, rx in KINDS if rx.search(text or "")), None)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Test monitors in Trunk's documented shape: pass-on-retry (failed and passed on the same code) that heals after quiet
|
|
2
|
+
days, failure rate and broken with separate switch-on and switch-off thresholds, and slow and timeout-inflation labels.
|
|
3
|
+
Each test's status is kept in the data branch with the day it began, and every change is logged."""
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from fnmatch import fnmatchcase
|
|
6
|
+
|
|
7
|
+
from stackvitals.core.config import DEFAULTS
|
|
8
|
+
from stackvitals.core.stats import median, percentile
|
|
9
|
+
|
|
10
|
+
ACTIVE = {"flaky", "broken", "suspect"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _iso(dt: datetime) -> str:
|
|
14
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _cluster_start(events: list, quiet: timedelta) -> datetime | None:
|
|
18
|
+
# The first event of the latest unbroken stretch: flakes closer together than the heal time.
|
|
19
|
+
if not events:
|
|
20
|
+
return None
|
|
21
|
+
start = events[-1][0]
|
|
22
|
+
for i in range(len(events) - 1, 0, -1):
|
|
23
|
+
if events[i][0] - events[i - 1][0] >= quiet:
|
|
24
|
+
break
|
|
25
|
+
start = events[i - 1][0]
|
|
26
|
+
return start
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def evaluate(ev_all: list, ev_win: list, hist: list[tuple[datetime, bool]], runs_since: int, now: datetime, cfg: dict,
|
|
30
|
+
previous: dict | None = None, pass_ms=(), fail_ms=(), test_id: str | None = None) -> dict:
|
|
31
|
+
"""One test's state from its flake events (oldest first), its default-branch results ([(time, failed)], oldest
|
|
32
|
+
first) and, when kept, its previous status; plus labels and a mute."""
|
|
33
|
+
m = (cfg or DEFAULTS)["ci"]["monitors"]
|
|
34
|
+
was = set((previous or {}).get("monitors") or [])
|
|
35
|
+
fails = [f for _, f in hist]
|
|
36
|
+
b = m["broken"]
|
|
37
|
+
window = fails[-int(b["window_runs"]):] if b["window_runs"] else []
|
|
38
|
+
rate_b = sum(window) / len(window) if window else 0.0
|
|
39
|
+
streak = int(b["streak"])
|
|
40
|
+
by_streak = streak > 0 and len(fails) >= streak and all(fails[-streak:])
|
|
41
|
+
broken = by_streak or (len(window) >= b["window_runs"] and rate_b >= b["activate"]) or \
|
|
42
|
+
("broken" in was and bool(window) and rate_b >= b["resolve"])
|
|
43
|
+
p = m["pass_on_retry"]
|
|
44
|
+
quiet = timedelta(days=p["quiet_days"])
|
|
45
|
+
last = ev_all[-1][0] if ev_all else None
|
|
46
|
+
retry = len(ev_win) >= p["min_events"] or (len(ev_all) >= p["min_events"] and last is not None and now - last < quiet)
|
|
47
|
+
fr = m["failure_rate"]
|
|
48
|
+
win = fails[-int(fr["window_runs"]):] if fr["window_runs"] else []
|
|
49
|
+
rate = sum(win) / len(win) if win else None
|
|
50
|
+
by_rate = bool(fr["enabled"]) and len(win) >= fr["min_runs"] and rate is not None and \
|
|
51
|
+
(rate >= fr["activate"] or ("failure_rate" in was and rate > fr["resolve"]))
|
|
52
|
+
monitors = [name for name, on in (("broken", broken), ("pass_on_retry", retry), ("failure_rate", by_rate)) if on]
|
|
53
|
+
if broken:
|
|
54
|
+
state = "broken"
|
|
55
|
+
if by_streak:
|
|
56
|
+
i = len(fails)
|
|
57
|
+
while i > 0 and fails[i - 1]:
|
|
58
|
+
i -= 1
|
|
59
|
+
since = hist[i][0]
|
|
60
|
+
else:
|
|
61
|
+
since = next(t for t, f in hist[-len(window):] if f)
|
|
62
|
+
elif retry or by_rate:
|
|
63
|
+
state = "flaky"
|
|
64
|
+
since = _cluster_start(ev_all, quiet) if retry else next((t for t, f in hist[-len(win):] if f), None)
|
|
65
|
+
elif ev_win:
|
|
66
|
+
state, since = "suspect", ev_win[0][0]
|
|
67
|
+
elif len(ev_all) >= p["min_events"] and runs_since >= p["quiet_runs"]:
|
|
68
|
+
state, since = "fixed", last
|
|
69
|
+
else:
|
|
70
|
+
state, since = "ok", None
|
|
71
|
+
out = {"state": state, "monitors": monitors, "since": _iso(since) if since else None,
|
|
72
|
+
"failure_rate": round(rate, 4) if rate is not None else None, "labels": []}
|
|
73
|
+
if previous and previous.get("state") == state and previous.get("since"):
|
|
74
|
+
out["since"] = previous["since"]
|
|
75
|
+
pass_ms, fail_ms = [x for x in pass_ms if x is not None], [x for x in fail_ms if x is not None]
|
|
76
|
+
if pass_ms and median(pass_ms) > m["slow"]["p50_ms"]:
|
|
77
|
+
out["labels"].append("slow")
|
|
78
|
+
t = m["timeout_inflation"]
|
|
79
|
+
if pass_ms and len(fail_ms) >= t["min_failures"] and median(fail_ms) >= t["ratio"] * percentile(pass_ms, 0.95):
|
|
80
|
+
out["labels"].append("timeout_inflation")
|
|
81
|
+
today = now.date().isoformat()
|
|
82
|
+
for pattern, until in ((cfg or DEFAULTS)["ci"].get("mute") or {}).items():
|
|
83
|
+
if test_id and until >= today and fnmatchcase(test_id, pattern):
|
|
84
|
+
out["muted_until"] = until
|
|
85
|
+
break
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def transitions(previous: dict, tests: list[dict], now: datetime) -> tuple[dict, list[dict]]:
|
|
90
|
+
"""The status file to keep (tests that are flaky, broken or suspect, with the day it began) and the changes since the
|
|
91
|
+
last run, each one line for the transitions log."""
|
|
92
|
+
at = _iso(now)
|
|
93
|
+
previous = previous or {}
|
|
94
|
+
status, changes = {}, []
|
|
95
|
+
current = {t["id"]: t for t in tests}
|
|
96
|
+
for tid in sorted(set(current) | set(previous)):
|
|
97
|
+
t, prev = current.get(tid), previous.get(tid) or {}
|
|
98
|
+
state = t["state"] if t else "ok"
|
|
99
|
+
before = prev.get("state", "ok")
|
|
100
|
+
if state in ACTIVE:
|
|
101
|
+
since = prev["since"] if before == state and prev.get("since") else (t.get("since") or at)
|
|
102
|
+
status[tid] = {"state": state, "since": since, "monitors": t.get("monitors") or []}
|
|
103
|
+
if state != before:
|
|
104
|
+
changes.append({"at": at, "id": tid, "from": before, "to": state, "monitors": (t or {}).get("monitors") or []})
|
|
105
|
+
return status, changes
|
stackvitals/ci/owners.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Who owns a flaky test or job: CODEOWNERS (read with GitHub's rules) for the test's file, else for its workflow file.
|
|
2
|
+
CODEOWNERS and GitHub's error check are read once a day; the repo's file list at most once a day, for new test ids."""
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from stackvitals import codeowners
|
|
6
|
+
from stackvitals.ci.analyze import SHOWN
|
|
7
|
+
from stackvitals.ci.api import APIError, NotFound, OutOfBudget
|
|
8
|
+
|
|
9
|
+
SOURCE_EXT = {"py", "js", "jsx", "mjs", "cjs", "ts", "tsx", "rb", "go", "rs", "java", "kt", "kts", "scala", "cs", "php",
|
|
10
|
+
"swift", "ex", "exs", "c", "cc", "cpp", "m", "dart", "lua", "sh"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _path_like(part: str) -> bool:
|
|
14
|
+
return "/" in part or ("." in part and part.rsplit(".", 1)[-1] in SOURCE_EXT)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _one(found: list[str]) -> str | None:
|
|
18
|
+
return found[0] if len(found) == 1 else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_file(tid: str, files: list[str] | None) -> str | None:
|
|
22
|
+
"""The repo file a test lives in, confirmed against the repo's files: the path in a pytest-style id (or the one file
|
|
23
|
+
ending with it, for runners started in a subfolder), or the one file matching the dotted class name
|
|
24
|
+
(tests.test_calc -> tests/test_calc.py, com.acme.BarTest -> .../com/acme/BarTest.java). No guess when unsure."""
|
|
25
|
+
if not files:
|
|
26
|
+
return None
|
|
27
|
+
parts = tid.split("::")
|
|
28
|
+
if _path_like(parts[0]):
|
|
29
|
+
if parts[0] in files:
|
|
30
|
+
return parts[0]
|
|
31
|
+
return _one([f for f in files if f.endswith("/" + parts[0])])
|
|
32
|
+
if len(parts) < 2 or " " in parts[0]:
|
|
33
|
+
return None
|
|
34
|
+
names = parts[0].split("$", 1)[0].split(".")
|
|
35
|
+
for n in range(len(names), 0, -1):
|
|
36
|
+
stem = "/".join(names[:n])
|
|
37
|
+
found = [f for f in files if f.rsplit(".", 1)[0] == stem or f.rsplit(".", 1)[0].endswith("/" + stem)]
|
|
38
|
+
if found:
|
|
39
|
+
return _one(found)
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_codeowners(api, ref: str | None, store, today: str) -> dict:
|
|
44
|
+
"""The CODEOWNERS text GitHub uses (first of its three places), the owners its error check says it ignores, and the
|
|
45
|
+
lines it skips as invalid; read at most once a day. A failed read keeps the last answer and tries again next run."""
|
|
46
|
+
state = store.state()
|
|
47
|
+
kept = state.get("codeowners") or {}
|
|
48
|
+
answer = {"text": kept.get("text"), "unknown": set(kept.get("unknown") or []), "skip": set(kept.get("skip") or [])}
|
|
49
|
+
if kept.get("checked") == today:
|
|
50
|
+
return answer
|
|
51
|
+
text = None
|
|
52
|
+
try:
|
|
53
|
+
for path in codeowners.PATHS:
|
|
54
|
+
try:
|
|
55
|
+
text = api.file(path, ref).decode("utf-8", "replace")
|
|
56
|
+
break
|
|
57
|
+
except NotFound:
|
|
58
|
+
continue # GitHub only moves on to the next place when a file is missing
|
|
59
|
+
except (APIError, OutOfBudget):
|
|
60
|
+
return answer
|
|
61
|
+
unknown, skip = answer["unknown"], answer["skip"]
|
|
62
|
+
if text:
|
|
63
|
+
try:
|
|
64
|
+
errors = (api._get(f"/repos/{api.repo}/codeowners/errors", {"ref": ref} if ref else None) or {}).get("errors") or []
|
|
65
|
+
unknown = {m.group(1).lower() for e in errors if e.get("kind") == "Unknown owner"
|
|
66
|
+
and (m := re.search(r"make sure (\S+) exists", e.get("message") or ""))}
|
|
67
|
+
skip = {e["line"] for e in errors if e.get("kind") != "Unknown owner" and isinstance(e.get("line"), int)}
|
|
68
|
+
except (APIError, OutOfBudget):
|
|
69
|
+
pass # the error check is extra: without it every owner is kept as written
|
|
70
|
+
store.save_state({**state, "codeowners": {"checked": today, "text": text, "unknown": sorted(unknown), "skip": sorted(skip)}})
|
|
71
|
+
return {"text": text, "unknown": unknown, "skip": skip}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_files(api, ref: str | None, store, today: str, tids: list[str]) -> dict[str, str]:
|
|
75
|
+
"""Each test's file, checked against the repo's file list (one request, at most once a day, only for new ids)."""
|
|
76
|
+
state = store.state()
|
|
77
|
+
known = dict(state.get("test_files") or {})
|
|
78
|
+
missing = [t for t in tids if t not in known]
|
|
79
|
+
if missing and state.get("tree_checked") != today:
|
|
80
|
+
try:
|
|
81
|
+
tree = api._get(f"/repos/{api.repo}/git/trees/{ref}", {"recursive": "1"}) or {}
|
|
82
|
+
files = [e["path"] for e in tree.get("tree") or [] if e.get("type") == "blob"]
|
|
83
|
+
for tid in missing:
|
|
84
|
+
if (path := test_file(tid, files)):
|
|
85
|
+
known[tid] = path
|
|
86
|
+
except (APIError, OutOfBudget):
|
|
87
|
+
pass
|
|
88
|
+
store.save_state({**store.state(), "test_files": known, "tree_checked": today})
|
|
89
|
+
return {t: known[t] for t in tids if t in known}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def attach_owners(result: dict, co: dict, files: dict[str, str]) -> None:
|
|
93
|
+
"""Adds `owners` and `owners_from` (the path they were looked up for) to every test and job the dashboard lists."""
|
|
94
|
+
if not co.get("text"):
|
|
95
|
+
return
|
|
96
|
+
rules, unknown = codeowners.parse(co["text"], co.get("skip") or ()), co.get("unknown") or set()
|
|
97
|
+
for t in result.get("tests") or []:
|
|
98
|
+
if t["state"] in SHOWN:
|
|
99
|
+
path = files.get(t["id"])
|
|
100
|
+
owners = codeowners.owners_for(path, rules, unknown) if path else []
|
|
101
|
+
if not owners and t.get("workflow"):
|
|
102
|
+
path, owners = t["workflow"], codeowners.owners_for(t["workflow"], rules, unknown)
|
|
103
|
+
t["owners"], t["owners_from"] = owners, path
|
|
104
|
+
for j in result.get("jobs") or []:
|
|
105
|
+
if j["state"] in SHOWN and j.get("workflow"):
|
|
106
|
+
j["owners"], j["owners_from"] = codeowners.owners_for(j["workflow"], rules, unknown), j["workflow"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def owners_line(item: dict, mention: bool = False) -> str:
|
|
110
|
+
"""Markdown naming the owners; code-formatted names notify nobody unless mentions are turned on."""
|
|
111
|
+
if not item.get("owners"):
|
|
112
|
+
return ""
|
|
113
|
+
# A path can't close the code format early and slip a live @-mention into the issue.
|
|
114
|
+
where = re.sub(r"[`\r\n]", "", item.get("owners_from") or "")
|
|
115
|
+
names = " ".join(item["owners"]) if mention else ", ".join(f"`{o}`" for o in item["owners"])
|
|
116
|
+
return f"Owners (CODEOWNERS for `{where}`): {names}"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Quarantine at test time, so a flaky test stops failing the suite without wrapping the CI command.
|
|
2
|
+
|
|
3
|
+
stackvitals ci quarantine --print jest # flags for the test runner, from the quarantine list
|
|
4
|
+
pytest $(stackvitals ci quarantine --print pytest) # writes .stackvitals-quarantine.json, prints -p ...
|
|
5
|
+
|
|
6
|
+
pytest runs a quarantined test as an expected failure (it still runs and reports, but can't fail the suite); Jest,
|
|
7
|
+
Vitest, go test, Playwright, Maven Surefire and dotnet test leave it out by name. Other runners (RSpec, Gradle): wrap the
|
|
8
|
+
command with `stackvitals ci quarantine -- <command>`. Only tests on the list are touched, and an empty or unreadable list touches none.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parts(tid: str) -> tuple[str, str]:
|
|
17
|
+
# "file::class::name" (JUnit, Jest) or "pkg::TestName/sub" (Go): what's before the last part, and the last part.
|
|
18
|
+
head, _, name = tid.rpartition("::")
|
|
19
|
+
return head, name
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def runtime_args(ids: list[str] | set[str], runner: str) -> list[str]:
|
|
23
|
+
"""The command-line arguments that keep quarantined tests from failing a run of `runner`."""
|
|
24
|
+
ids = sorted(ids or [])
|
|
25
|
+
if not ids:
|
|
26
|
+
return []
|
|
27
|
+
if runner == "pytest":
|
|
28
|
+
return ["-p", "stackvitals.ci.quarantine_runtime"]
|
|
29
|
+
if runner in ("jest", "vitest"):
|
|
30
|
+
names = sorted({re.escape(_parts(t)[1]) for t in ids if _parts(t)[1]})
|
|
31
|
+
flag = "--testNamePattern" if runner == "jest" else "-t"
|
|
32
|
+
# Runners match the full name (describe blocks first), so the test's own name is matched at the end.
|
|
33
|
+
return [flag, f"^(?!.*(?:{'|'.join(names)})$).*"]
|
|
34
|
+
if runner == "go":
|
|
35
|
+
# go test -skip takes one pattern per level; only top-level tests can be listed together safely.
|
|
36
|
+
tops = sorted({re.escape(_parts(t)[1]) for t in ids
|
|
37
|
+
if re.fullmatch(r"(Test|Fuzz|Example)[A-Z0-9_]\w*", _parts(t)[1] or "")})
|
|
38
|
+
return ["-skip", f"^(?:{'|'.join(tops)})$"] if tops else []
|
|
39
|
+
if runner == "playwright":
|
|
40
|
+
# The full title ends with the test's own name; --grep-invert leaves those out.
|
|
41
|
+
names = sorted({re.escape(_parts(t)[1]) for t in ids if _parts(t)[1]})
|
|
42
|
+
return ["--grep-invert", f"(?:{'|'.join(names)})$"]
|
|
43
|
+
if runner == "maven":
|
|
44
|
+
# Surefire 2.19+: exclusion-only patterns run everything else; the class is the last part of the classname.
|
|
45
|
+
pats = sorted({f"!{_parts(t)[0].split('::')[-1].split('.')[-1]}#{_parts(t)[1]}" for t in ids if _parts(t)[0] and _parts(t)[1]})
|
|
46
|
+
return [f"-Dtest={','.join(pats)}", "-Dsurefire.failIfNoSpecifiedTests=false"] if pats else []
|
|
47
|
+
if runner == "dotnet":
|
|
48
|
+
fqns = sorted({f"{_parts(t)[0].split('::')[-1]}.{_parts(t)[1]}" for t in ids if _parts(t)[0] and _parts(t)[1]})
|
|
49
|
+
return ["--filter", "&".join(f"FullyQualifiedName!={f}" for f in fqns)] if fqns else []
|
|
50
|
+
raise ValueError(f"runner is one of pytest, jest, vitest, go, playwright, maven, dotnet, not {runner!r}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
LIST_FILE = ".stackvitals-quarantine.json" # written by `stackvitals ci quarantine --print pytest`
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _quarantined() -> set[str]:
|
|
57
|
+
src = os.environ.get("STACKVITALS_QUARANTINE") or LIST_FILE
|
|
58
|
+
try:
|
|
59
|
+
items = json.loads(Path(src).read_text(encoding="utf-8"))
|
|
60
|
+
except (OSError, ValueError):
|
|
61
|
+
return set()
|
|
62
|
+
return {str(i) for i in items} if isinstance(items, list) else set()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _tokens(text: str) -> list[str]:
|
|
66
|
+
# "tests.test_a.TestCls" and "tests/test_a.py::TestCls" both read as tests, test_a, TestCls.
|
|
67
|
+
return [p for p in re.split(r"[./:\\]+", text) if p and p != "py"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _matches(tid: str, nodeid: str, name: str) -> bool:
|
|
71
|
+
# Same test name, and the report's module and class end the same way as the node id's file and class (either may
|
|
72
|
+
# carry more folders in front, since the two can start from different roots).
|
|
73
|
+
head, last = _parts(tid)
|
|
74
|
+
if last != name:
|
|
75
|
+
return False
|
|
76
|
+
a, b = _tokens(head), _tokens(nodeid.rpartition("::")[0])
|
|
77
|
+
if not a or not b:
|
|
78
|
+
return not a
|
|
79
|
+
n = min(len(a), len(b))
|
|
80
|
+
return a[-n:] == b[-n:]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pytest_collection_modifyitems(config, items): # pragma: no cover - exercised through pytest itself
|
|
84
|
+
import pytest
|
|
85
|
+
ids = _quarantined()
|
|
86
|
+
if not ids:
|
|
87
|
+
return
|
|
88
|
+
for item in items:
|
|
89
|
+
if any(_matches(t, item.nodeid, item.name) for t in ids):
|
|
90
|
+
item.add_marker(pytest.mark.xfail(reason="quarantined by StackVitals (known flaky)", strict=False))
|