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/shards.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Test shards that finish together: test files (or classes) split into N groups by their recorded times, from the
|
|
2
|
+
newest test reports on the default branch or from report files on this machine."""
|
|
3
|
+
import glob
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from stackvitals.ci.api import APIError, OutOfBudget
|
|
10
|
+
from stackvitals.ci.collect import TEST_REPORTS
|
|
11
|
+
from stackvitals.ci.testreports import parse_archive, parse_report
|
|
12
|
+
|
|
13
|
+
MAX_BYTES = 50_000_000
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def unit(tid: str, py_paths: bool = False) -> str:
|
|
17
|
+
"""What a test belongs to: the first part of its id (a file, or a class); py_paths turns a dotted pytest module into
|
|
18
|
+
its file."""
|
|
19
|
+
head = tid.split("::", 1)[0]
|
|
20
|
+
if py_paths and "/" not in head and not head.endswith(".py"):
|
|
21
|
+
parts = head.split(".")
|
|
22
|
+
while len(parts) > 1 and parts[-1][:1].isupper():
|
|
23
|
+
parts.pop()
|
|
24
|
+
head = "/".join(parts) + ".py"
|
|
25
|
+
return head
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def split(times: list, n: int, extra=(), py_paths: bool = False) -> list[dict]:
|
|
29
|
+
"""[{"files", "ms"}] for n shards: the longest first, each onto the lightest shard. Files with no recorded time count
|
|
30
|
+
as a typical file."""
|
|
31
|
+
totals = {}
|
|
32
|
+
for tid, ms in times:
|
|
33
|
+
u = unit(tid, py_paths)
|
|
34
|
+
if not u or u.startswith("-") or any(c.isspace() for c in u):
|
|
35
|
+
continue # it would reach the test command as an option ("--co"), so it is never passed on
|
|
36
|
+
totals[u] = totals.get(u, 0) + (ms or 0)
|
|
37
|
+
typical = sorted(totals.values())[len(totals) // 2] if totals else 0
|
|
38
|
+
for f in extra:
|
|
39
|
+
totals.setdefault(f, typical)
|
|
40
|
+
shards = [{"files": [], "ms": 0} for _ in range(max(1, n))]
|
|
41
|
+
for u, ms in sorted(totals.items(), key=lambda x: (-x[1], x[0])):
|
|
42
|
+
s = min(shards, key=lambda s: (s["ms"], len(s["files"])))
|
|
43
|
+
s["files"].append(u)
|
|
44
|
+
s["ms"] += ms
|
|
45
|
+
for s in shards:
|
|
46
|
+
s["files"].sort()
|
|
47
|
+
return shards
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def latest_times(api, branch: str, pattern: str = TEST_REPORTS, look: int = 20) -> tuple[list, dict | None]:
|
|
51
|
+
"""Every test's time from the newest successful run on `branch` that kept test reports, and that run."""
|
|
52
|
+
# Push runs only: a fork's pull request from its own "main" would otherwise pass for this repo's default branch.
|
|
53
|
+
body = api._get(f"/repos/{api.repo}/actions/runs", {"branch": branch, "event": "push", "status": "success", "per_page": look})
|
|
54
|
+
for run in body.get("workflow_runs") or []:
|
|
55
|
+
arts = [a for a in api.artifacts(run["id"]) if not a.get("expired") and (a.get("size_in_bytes") or 0) <= MAX_BYTES
|
|
56
|
+
and re.search(pattern, a.get("name") or "", re.I)]
|
|
57
|
+
times = []
|
|
58
|
+
for a in arts:
|
|
59
|
+
data = api.download(a["id"])
|
|
60
|
+
try:
|
|
61
|
+
times += parse_archive(data)["all"]
|
|
62
|
+
except Exception: # an unreadable report is skipped, never the end of the command
|
|
63
|
+
continue
|
|
64
|
+
if times:
|
|
65
|
+
return times, run
|
|
66
|
+
return [], None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _local(patterns: list[str]) -> list:
|
|
70
|
+
times = []
|
|
71
|
+
for pattern in patterns:
|
|
72
|
+
for name in sorted(glob.glob(pattern, recursive=True)):
|
|
73
|
+
parsed = parse_report(name, Path(name).read_bytes())
|
|
74
|
+
if parsed:
|
|
75
|
+
times += parsed["all"]
|
|
76
|
+
return times
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _dur(ms: int) -> str:
|
|
80
|
+
s = round(ms / 1000)
|
|
81
|
+
return f"{s // 60}m {s % 60:02d}s" if s >= 60 else f"{s}s"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run(args, make_api, tok: str | None) -> int:
|
|
85
|
+
if args.n < 1 or args.index is not None and not 1 <= args.index <= args.n:
|
|
86
|
+
print("--n must be 1 or more and --index between 1 and --n.", file=sys.stderr)
|
|
87
|
+
return 2
|
|
88
|
+
source = "report files on this machine"
|
|
89
|
+
if args.reports:
|
|
90
|
+
times = _local(args.reports)
|
|
91
|
+
elif args.repo and tok:
|
|
92
|
+
api = make_api(tok, args.repo, max_requests=60)
|
|
93
|
+
try:
|
|
94
|
+
branch = api._get(f"/repos/{args.repo}").get("default_branch") or "main"
|
|
95
|
+
times, found = latest_times(api, branch)
|
|
96
|
+
except (APIError, OutOfBudget) as exc:
|
|
97
|
+
print(f"GitHub refused: {exc}", file=sys.stderr)
|
|
98
|
+
return 1
|
|
99
|
+
source = f"run {found['html_url']}" if found else source
|
|
100
|
+
else:
|
|
101
|
+
print("Give owner/name (with a GitHub token) or --reports 'path/*.xml'.", file=sys.stderr)
|
|
102
|
+
return 2
|
|
103
|
+
if not times:
|
|
104
|
+
print("No test reports with times found (a test report in an artifact whose name says test results).", file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
extra = sorted({f for pattern in args.files for f in glob.glob(pattern, recursive=True)})
|
|
107
|
+
shards = split(times, args.n, extra, args.py_paths)
|
|
108
|
+
if args.index is not None:
|
|
109
|
+
files = shards[args.index - 1]["files"]
|
|
110
|
+
if not files:
|
|
111
|
+
# An empty argument list makes most test runners run everything, so say so where a person will see it.
|
|
112
|
+
print(f"Shard {args.index} is empty (fewer files than shards): skip the test command in it.", file=sys.stderr)
|
|
113
|
+
print(" ".join(files))
|
|
114
|
+
elif args.json:
|
|
115
|
+
print(json.dumps({"source": source, "tests": len(times), "shards": shards}, indent=1))
|
|
116
|
+
else:
|
|
117
|
+
print(f"{args.n} shards from {len(times)} tests in {sum(len(s['files']) for s in shards)} files ({source}):")
|
|
118
|
+
for k, s in enumerate(shards, 1):
|
|
119
|
+
print(f"Shard {k}, about {_dur(s['ms'])}: {' '.join(s['files'])}")
|
|
120
|
+
return 0
|
stackvitals/ci/share.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Opt-in shared benchmarks ([benchmarks] share = true): this repo's CI figures, rounded and without its name unless
|
|
2
|
+
allowed, as a prefilled issue on StackVitals' public repo. Nothing is sent by the Action: a person opens the link and
|
|
3
|
+
files the issue. Once 10+ repos of a kind have shared, their peer group is published beside the public one."""
|
|
4
|
+
import json
|
|
5
|
+
import secrets
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
from mergescout.bench.ci import tier
|
|
11
|
+
from stackvitals.ci.bench import BENCH_REPO, PUBLIC_HOME
|
|
12
|
+
|
|
13
|
+
KIND, SCHEMA, MIN_PUSHES = "ci-benchmark", 1, 20
|
|
14
|
+
FIELDS = ("schema", "kind", "id", "month", "language", "tier", "pushes", "median_s", "p90_s", "first_try_share",
|
|
15
|
+
"fail_share", "rerun_share")
|
|
16
|
+
ID_FILE = "ci/share-id" # in the data branch
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def share_id(root: Path) -> str:
|
|
20
|
+
"""A random id kept in the data branch, so a later month replaces this repo's figures; it says nothing about the repo."""
|
|
21
|
+
path = Path(root) / ID_FILE
|
|
22
|
+
try:
|
|
23
|
+
found = path.read_text(encoding="utf-8").strip()
|
|
24
|
+
except OSError:
|
|
25
|
+
found = ""
|
|
26
|
+
if len(found) == 16 and all(c in "0123456789abcdef" for c in found):
|
|
27
|
+
return found
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
found = secrets.token_hex(8)
|
|
30
|
+
path.write_text(found + "\n", encoding="utf-8")
|
|
31
|
+
return found
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def figures(stats: dict | None, language: str | None, stars: int | None, sid: str, now: datetime,
|
|
35
|
+
name: str | None = None) -> dict | None:
|
|
36
|
+
"""The shared figures, or None with too few pushes to share safely."""
|
|
37
|
+
if not stats or stats.get("pushes", 0) < MIN_PUSHES:
|
|
38
|
+
return None
|
|
39
|
+
doc = {"schema": SCHEMA, "kind": KIND, "id": sid, "month": f"{now:%Y-%m}", "language": language or "other",
|
|
40
|
+
"tier": tier(stars), "pushes": int(round(stats["pushes"], -1)), "median_s": int(round(stats["median_s"] / 30) * 30),
|
|
41
|
+
"p90_s": int(round(stats["p90_s"] / 60) * 60), "first_try_share": round(stats["first_try_share"], 2),
|
|
42
|
+
"fail_share": round(stats["fail_share"], 2), "rerun_share": round(stats["rerun_share"], 2)}
|
|
43
|
+
return {**doc, "repo": name} if name else doc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def link(doc: dict) -> str:
|
|
47
|
+
# The issue form adds the "benchmark" label for anyone; a plain new-issue link drops labels for people without triage rights.
|
|
48
|
+
title = f"Benchmark figures: {doc['language']}, {doc['tier']} stars, {doc['month']}"
|
|
49
|
+
# Figures are filed on the public home once there is one; the daily collector reads both repos.
|
|
50
|
+
return f"https://github.com/{PUBLIC_HOME or BENCH_REPO}/issues/new?" + urlencode(
|
|
51
|
+
{"template": "benchmark.yml", "title": title, "figures": json.dumps(doc, indent=1)})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def summary_line(doc: dict | None) -> str:
|
|
55
|
+
if not doc:
|
|
56
|
+
return f"Shared benchmarks are on, but this repo had fewer than {MIN_PUSHES} pushes in the window; nothing to share yet."
|
|
57
|
+
who = f"named {doc['repo']}" if doc.get("repo") else "without the repo's name"
|
|
58
|
+
return (f"Shared benchmarks: [file this month's rounded CI figures]({link(doc)}) ({who}; the issue shows the account "
|
|
59
|
+
"that files it). Peer groups are published once 10+ repos of a kind have shared.")
|
stackvitals/ci/store.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import gzip
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from datetime import date, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SCHEMA = "1"
|
|
8
|
+
KNOWN = "tests-seen.json.gz"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DataStore:
|
|
12
|
+
# History lives under <root>/ci: one NDJSON file per collection day, compressed once the day is over.
|
|
13
|
+
def __init__(self, root: Path):
|
|
14
|
+
self.dir = Path(root) / "ci"
|
|
15
|
+
self.days = self.dir / "days"
|
|
16
|
+
self.days.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
version = self.dir / "VERSION"
|
|
18
|
+
if not version.exists():
|
|
19
|
+
version.write_text(SCHEMA + "\n", encoding="utf-8")
|
|
20
|
+
|
|
21
|
+
def add(self, records: list[dict], day: str) -> None:
|
|
22
|
+
if not records:
|
|
23
|
+
return
|
|
24
|
+
with (self.days / f"{day}.ndjson").open("a", encoding="utf-8", newline="\n") as f:
|
|
25
|
+
for r in records:
|
|
26
|
+
f.write(json.dumps(r, separators=(",", ":"), ensure_ascii=False) + "\n")
|
|
27
|
+
|
|
28
|
+
def _lines(self):
|
|
29
|
+
for path in sorted(self.days.iterdir()):
|
|
30
|
+
if path.name.endswith(".ndjson.gz"):
|
|
31
|
+
text = gzip.decompress(path.read_bytes()).decode("utf-8")
|
|
32
|
+
elif path.name.endswith(".ndjson"):
|
|
33
|
+
text = path.read_text(encoding="utf-8")
|
|
34
|
+
else:
|
|
35
|
+
continue
|
|
36
|
+
for line in text.splitlines():
|
|
37
|
+
if line.strip():
|
|
38
|
+
yield json.loads(line)
|
|
39
|
+
|
|
40
|
+
def load(self) -> dict[str, list[dict]]:
|
|
41
|
+
# Later records win, so a run seen again keeps its newest state.
|
|
42
|
+
runs, jobs, tests = {}, {}, {}
|
|
43
|
+
for r in self._lines():
|
|
44
|
+
if r.get("t") == "run":
|
|
45
|
+
runs[(r["id"], r["att"])] = r
|
|
46
|
+
elif r.get("t") == "job":
|
|
47
|
+
jobs[r["id"]] = r
|
|
48
|
+
elif r.get("t") == "tests":
|
|
49
|
+
tests[r["art"]] = r
|
|
50
|
+
return {"runs": list(runs.values()), "jobs": list(jobs.values()), "tests": list(tests.values())}
|
|
51
|
+
|
|
52
|
+
def state(self) -> dict:
|
|
53
|
+
path = self.dir / "state.json"
|
|
54
|
+
try:
|
|
55
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
56
|
+
except (OSError, ValueError):
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
def save_state(self, state: dict) -> None:
|
|
60
|
+
path = self.dir / "state.json"
|
|
61
|
+
tmp = path.with_suffix(".tmp")
|
|
62
|
+
tmp.write_text(json.dumps(state, indent=1, sort_keys=True), encoding="utf-8")
|
|
63
|
+
os.replace(tmp, path)
|
|
64
|
+
|
|
65
|
+
def known_tests(self) -> set | None:
|
|
66
|
+
"""Every test id seen so far, or None before the first collect that read test reports."""
|
|
67
|
+
try:
|
|
68
|
+
return set(json.loads(gzip.decompress((self.dir / KNOWN).read_bytes())))
|
|
69
|
+
except (OSError, ValueError, EOFError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def save_known_tests(self, ids: set) -> None:
|
|
73
|
+
data = gzip.compress(json.dumps(sorted(ids), separators=(",", ":")).encode("utf-8"), mtime=0)
|
|
74
|
+
path = self.dir / KNOWN
|
|
75
|
+
# Written only when a test was added, so most runs leave the data branch alone.
|
|
76
|
+
if not path.exists() or path.read_bytes() != data:
|
|
77
|
+
path.write_bytes(data)
|
|
78
|
+
|
|
79
|
+
def compress(self, today: str) -> None:
|
|
80
|
+
for path in sorted(self.days.glob("*.ndjson")):
|
|
81
|
+
if path.name.split(".")[0] < today:
|
|
82
|
+
# A past day is kept compressed; late re-runs are merged in and it is compressed again.
|
|
83
|
+
gz = path.with_name(path.name + ".gz")
|
|
84
|
+
old = gzip.decompress(gz.read_bytes()) if gz.exists() else b""
|
|
85
|
+
gz.write_bytes(gzip.compress(old + path.read_bytes(), mtime=0))
|
|
86
|
+
path.unlink()
|
|
87
|
+
|
|
88
|
+
def compact(self, today: str, retention_days: int = 180) -> None:
|
|
89
|
+
oldest = (date.fromisoformat(today) - timedelta(days=retention_days)).isoformat()
|
|
90
|
+
for path in sorted(self.days.iterdir()):
|
|
91
|
+
if path.name.split(".")[0] < oldest:
|
|
92
|
+
path.unlink()
|
|
93
|
+
self.compress(today)
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import zipfile
|
|
7
|
+
|
|
8
|
+
from defusedxml import DefusedXmlException
|
|
9
|
+
from defusedxml.ElementTree import ParseError, fromstring
|
|
10
|
+
|
|
11
|
+
from stackvitals.ci import coverage
|
|
12
|
+
from stackvitals.ci.fingerprint import analyse
|
|
13
|
+
|
|
14
|
+
SLOW_KEEP = 200
|
|
15
|
+
SKIP_KEEP = 200
|
|
16
|
+
# Caps per report: a hostile or giant report can't bloat the data branch (ids longer than this are cut).
|
|
17
|
+
MAX_ID, MAX_ROWS, MAX_ALL = 300, 500, 50_000
|
|
18
|
+
FAIL_TAGS = {"failure", "error"}
|
|
19
|
+
FLAKY_TAGS = {"flakyFailure", "flakyError"}
|
|
20
|
+
REPORT_FILES = (".xml", ".trx", ".json", ".jsonl", ".ndjson")
|
|
21
|
+
# A random test order's seed, so a flaky failure can be replayed: Surefire's runOrder seed, `go test -shuffle`.
|
|
22
|
+
SEED_PROPERTY = re.compile(r"seed|randomly", re.I)
|
|
23
|
+
GO_SEED = re.compile(r"-test\.shuffle (\d+)")
|
|
24
|
+
GO_LINE = re.compile(r"^\s*([\w.\-]+\.go:\d+: .*)$")
|
|
25
|
+
# Hosted runners' checkout folders (Linux and macOS, Windows, containers), so a test file reads as a repo path.
|
|
26
|
+
RUNNER_ROOT = re.compile(r"^(?:.*?[\\/]runner[\\/]work[\\/][^\\/]+[\\/][^\\/]+|[A-Za-z]:[\\/]a[\\/][^\\/]+[\\/][^\\/]+"
|
|
27
|
+
r"|/github/workspace)[\\/]")
|
|
28
|
+
TRX_FAILED = {"failed", "error", "timeout", "aborted"}
|
|
29
|
+
TRX_SKIPPED = {"notexecuted", "inconclusive", "pending", "notrunnable", "disconnected"}
|
|
30
|
+
JEST_SKIPPED = {"pending", "skipped", "todo", "disabled"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _failure(tid: str, type_attr: str | None, message: str | None, text: str | None, ms: int | None) -> list:
|
|
34
|
+
"""[test id, per-test key, short message, cause, where, type, ms]: older records hold only the first three."""
|
|
35
|
+
f = analyse(type_attr, message, text)
|
|
36
|
+
shown = message or (f"{f.type}: {f.message}" if f.type else f.message) or text
|
|
37
|
+
return [tid, f.key(tid), _short(shown), f.cause, f.where, f.type, ms]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _short(message: str | None) -> str:
|
|
41
|
+
text = (message or "").strip()
|
|
42
|
+
return text.splitlines()[0][:200] if text else ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _cap_id(tid: str) -> str:
|
|
46
|
+
# A long id is cut, with a short hash of the whole so two long ids never become one test.
|
|
47
|
+
return tid if len(tid) <= MAX_ID else f"{tid[:MAX_ID - 9]}~{hashlib.sha1(tid.encode()).hexdigest()[:8]}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _millis(value, scale: float = 1) -> int:
|
|
51
|
+
# Report numbers are untrusted: anything not finite or beyond a few months of run time counts as 0.
|
|
52
|
+
try:
|
|
53
|
+
ms = float(value) * scale
|
|
54
|
+
except (TypeError, ValueError, OverflowError):
|
|
55
|
+
return 0
|
|
56
|
+
return round(ms) if math.isfinite(ms) and 0 <= ms < 1e10 else 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _text(value) -> str | None:
|
|
60
|
+
return value if isinstance(value, str) else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _repo_path(path) -> str | None:
|
|
64
|
+
return RUNNER_ROOT.sub("", path).replace("\\", "/") if isinstance(path, str) and path else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _empty() -> dict:
|
|
68
|
+
# "all" (every test and its time) is read for new-test detection and shards, and never stored per report.
|
|
69
|
+
return {"total": 0, "skipped": 0, "failed": [], "flaky": [], "slow": [], "sk": [], "all": []}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _case() -> dict:
|
|
73
|
+
return {"fail": None, "passed": False, "flaky": False, "skipped": False, "ms": 0, "detail": None}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _outcomes(seen: dict[str, dict]) -> dict:
|
|
77
|
+
"""Each test's outcomes, gathered over every time the report lists it, as one report: a failure that also passed
|
|
78
|
+
(a retry inside the same job) is a flake."""
|
|
79
|
+
out = _empty()
|
|
80
|
+
for tid, info in seen.items():
|
|
81
|
+
out["total"] += 1
|
|
82
|
+
if info["fail"] is not None and info["passed"] or info["flaky"]:
|
|
83
|
+
# Failed, then passed on a retry inside the same job (Gradle test-retry, pytest-rerunfailures, Surefire).
|
|
84
|
+
out["flaky"].append(tid)
|
|
85
|
+
elif info["fail"] is not None:
|
|
86
|
+
out["failed"].append(_failure(tid, *info["detail"], info["ms"]))
|
|
87
|
+
elif info["skipped"] and not info["passed"]:
|
|
88
|
+
out["skipped"] += 1
|
|
89
|
+
out["slow"] = sorted(([tid, i["ms"]] for tid, i in seen.items()), key=lambda x: -x[1])[:SLOW_KEEP]
|
|
90
|
+
out["sk"] = [tid for tid, i in seen.items() if i["skipped"] and not i["passed"] and i["fail"] is None and not i["flaky"]][:SKIP_KEEP]
|
|
91
|
+
out["all"] = [[tid, i["ms"]] for tid, i in seen.items()]
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _junit(root) -> dict:
|
|
96
|
+
# A test can appear several times when a retry plugin re-ran it, so outcomes are gathered per test first.
|
|
97
|
+
seen: dict[str, dict] = {}
|
|
98
|
+
for case in root.iter("testcase"):
|
|
99
|
+
name, cls, file = case.get("name") or "?", case.get("classname"), case.get("file")
|
|
100
|
+
info = seen.setdefault(_cap_id("::".join(p for p in (file, cls, name) if p)), _case())
|
|
101
|
+
tags = {child.tag: child for child in case}
|
|
102
|
+
failure = next((tags[t] for t in FAIL_TAGS if t in tags), None)
|
|
103
|
+
if failure is not None:
|
|
104
|
+
info["fail"] = info["fail"] or (failure.get("message") or failure.text or "")
|
|
105
|
+
# The body holds the stack the fingerprint needs; only its result is kept.
|
|
106
|
+
info["detail"] = info["detail"] or (failure.get("type"), failure.get("message"), failure.text)
|
|
107
|
+
elif FLAKY_TAGS & tags.keys():
|
|
108
|
+
info["flaky"] = True
|
|
109
|
+
elif "skipped" in tags:
|
|
110
|
+
info["skipped"] = True
|
|
111
|
+
else:
|
|
112
|
+
info["passed"] = True
|
|
113
|
+
info["ms"] = max(info["ms"], _millis(case.get("time") or 0, 1000))
|
|
114
|
+
out = _outcomes(seen)
|
|
115
|
+
seed = next((p.get("value") for p in root.iter("property") if SEED_PROPERTY.search(p.get("name") or "") and p.get("value")), None)
|
|
116
|
+
return {**out, "seed": seed[:40]} if seed else out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _span(value) -> int:
|
|
120
|
+
# TRX durations read "00:00:01.5000000".
|
|
121
|
+
m = re.match(r"^(\d+):(\d+):(\d+(?:\.\d+)?)$", value or "")
|
|
122
|
+
return _millis(int(m[1]) * 3600 + int(m[2]) * 60 + float(m[3]), 1000) if m else 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _trx(root) -> dict:
|
|
126
|
+
"""A .NET TRX file (dotnet test --logger trx): each result named by its class and test name, parameters included."""
|
|
127
|
+
ns = root.tag[:root.tag.index("}") + 1] if root.tag.startswith("{") else ""
|
|
128
|
+
classes = {}
|
|
129
|
+
for unit in root.iter(f"{ns}UnitTest"):
|
|
130
|
+
method = unit.find(f"{ns}TestMethod")
|
|
131
|
+
if method is not None:
|
|
132
|
+
classes[unit.get("id")] = method.get("className")
|
|
133
|
+
seen: dict[str, dict] = {}
|
|
134
|
+
for result in root.iter(f"{ns}UnitTestResult"):
|
|
135
|
+
name = result.get("testName") or "?"
|
|
136
|
+
info = seen.setdefault(_cap_id("::".join(p for p in (classes.get(result.get("testId")), name) if p)), _case())
|
|
137
|
+
outcome = (result.get("outcome") or "").lower()
|
|
138
|
+
if outcome in TRX_FAILED:
|
|
139
|
+
error = result.find(f"{ns}Output/{ns}ErrorInfo")
|
|
140
|
+
message = (error.findtext(f"{ns}Message") or "").strip() if error is not None else ""
|
|
141
|
+
trace = error.findtext(f"{ns}StackTrace") if error is not None else None
|
|
142
|
+
info["fail"] = info["fail"] or message
|
|
143
|
+
info["detail"] = info["detail"] or (None, message or None, trace or message)
|
|
144
|
+
elif outcome == "passed":
|
|
145
|
+
info["passed"] = True
|
|
146
|
+
elif outcome in TRX_SKIPPED:
|
|
147
|
+
info["skipped"] = True
|
|
148
|
+
info["ms"] = max(info["ms"], _span(result.get("duration")))
|
|
149
|
+
return _outcomes(seen)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _xml(data: bytes) -> dict | None:
|
|
153
|
+
try:
|
|
154
|
+
root = fromstring(data, forbid_dtd=True)
|
|
155
|
+
except (DefusedXmlException, ParseError, ValueError):
|
|
156
|
+
return None
|
|
157
|
+
return _trx(root) if root.tag.rsplit("}", 1)[-1] == "TestRun" else _junit(root)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _ctrf(doc: dict) -> dict | None:
|
|
161
|
+
try:
|
|
162
|
+
tests = doc["results"]["tests"]
|
|
163
|
+
except (KeyError, TypeError):
|
|
164
|
+
return None
|
|
165
|
+
out, slow = _empty(), []
|
|
166
|
+
for t in tests if isinstance(tests, list) else []:
|
|
167
|
+
if not isinstance(t, dict):
|
|
168
|
+
continue
|
|
169
|
+
tid = _cap_id("::".join(str(p) for p in (t.get("filePath") or t.get("suite"), t.get("name") or "?") if p))
|
|
170
|
+
out["total"] += 1
|
|
171
|
+
status = t.get("status")
|
|
172
|
+
retries = t.get("retries")
|
|
173
|
+
if status == "failed":
|
|
174
|
+
ms = _millis(t["duration"]) if isinstance(t.get("duration"), (int, float)) else None
|
|
175
|
+
message = _text(t.get("message"))
|
|
176
|
+
out["failed"].append(_failure(tid, None, message, _text(t.get("trace")) or message, ms))
|
|
177
|
+
elif status == "passed" and (t.get("flaky") is True or isinstance(retries, (int, float)) and retries > 0):
|
|
178
|
+
out["flaky"].append(tid)
|
|
179
|
+
elif status in ("skipped", "pending"):
|
|
180
|
+
out["skipped"] += 1
|
|
181
|
+
if len(out["sk"]) < SKIP_KEEP:
|
|
182
|
+
out["sk"].append(tid)
|
|
183
|
+
ms = _millis(t["duration"]) if isinstance(t.get("duration"), (int, float)) else 0
|
|
184
|
+
out["all"].append([tid, ms])
|
|
185
|
+
if isinstance(t.get("duration"), (int, float)):
|
|
186
|
+
slow.append([tid, ms])
|
|
187
|
+
out["slow"] = sorted(slow, key=lambda x: -x[1])[:SLOW_KEEP]
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _jest(doc: dict) -> dict:
|
|
192
|
+
"""Jest's --json and Vitest's json reporter: each test named by its file and full name; a test that passed after
|
|
193
|
+
retries (jest.retryTimes, Vitest retry) is a flake."""
|
|
194
|
+
out, slow = _empty(), []
|
|
195
|
+
for suite in doc.get("testResults") or []:
|
|
196
|
+
if not isinstance(suite, dict):
|
|
197
|
+
continue
|
|
198
|
+
file = _repo_path(suite.get("name") or suite.get("testFilePath"))
|
|
199
|
+
for t in suite.get("assertionResults") or []:
|
|
200
|
+
if not isinstance(t, dict):
|
|
201
|
+
continue
|
|
202
|
+
name = t.get("fullName") or " ".join([*(str(a) for a in t.get("ancestorTitles") or []), str(t.get("title") or "?")])
|
|
203
|
+
tid = _cap_id("::".join(p for p in (file, str(name)) if p))
|
|
204
|
+
out["total"] += 1
|
|
205
|
+
status = t.get("status")
|
|
206
|
+
ms = _millis(t["duration"]) if isinstance(t.get("duration"), (int, float)) else 0
|
|
207
|
+
retried = (isinstance(t.get("invocations"), int) and t["invocations"] > 1) or bool(t.get("retryReasons"))
|
|
208
|
+
if status == "failed":
|
|
209
|
+
text = next((m for m in t.get("failureMessages") or [] if isinstance(m, str)), None)
|
|
210
|
+
out["failed"].append(_failure(tid, None, text, text, ms))
|
|
211
|
+
elif status == "passed" and retried:
|
|
212
|
+
out["flaky"].append(tid)
|
|
213
|
+
elif status in JEST_SKIPPED:
|
|
214
|
+
out["skipped"] += 1
|
|
215
|
+
if len(out["sk"]) < SKIP_KEEP:
|
|
216
|
+
out["sk"].append(tid)
|
|
217
|
+
out["all"].append([tid, ms])
|
|
218
|
+
if isinstance(t.get("duration"), (int, float)):
|
|
219
|
+
slow.append([tid, ms])
|
|
220
|
+
out["slow"] = sorted(slow, key=lambda x: -x[1])[:SLOW_KEEP]
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _go(data: bytes) -> dict | None:
|
|
225
|
+
"""`go test -json` (and gotestsum): one test per package and full name, subtests apart from their parents (a parent
|
|
226
|
+
fails whenever a subtest does); a test that failed and then passed in the same stream (a rerun) is a flake."""
|
|
227
|
+
seen: dict[str, dict] = {}
|
|
228
|
+
parents, seed, events = set(), None, 0
|
|
229
|
+
for line in data.splitlines():
|
|
230
|
+
try:
|
|
231
|
+
e = json.loads(line)
|
|
232
|
+
except ValueError:
|
|
233
|
+
continue # build output can sit between the events
|
|
234
|
+
if not isinstance(e, dict) or not isinstance(e.get("Action"), str):
|
|
235
|
+
continue
|
|
236
|
+
events += 1
|
|
237
|
+
output, name, pkg = _text(e.get("Output")), _text(e.get("Test")), _text(e.get("Package")) or ""
|
|
238
|
+
if output and seed is None and (m := GO_SEED.search(output)):
|
|
239
|
+
seed = m[1]
|
|
240
|
+
if not name:
|
|
241
|
+
continue
|
|
242
|
+
parts = name.split("/")
|
|
243
|
+
parents.update(f"{pkg}::{'/'.join(parts[:n])}" for n in range(1, len(parts)))
|
|
244
|
+
info = seen.setdefault(_cap_id(f"{pkg}::{name}" if pkg else name), {**_case(), "lines": []})
|
|
245
|
+
action = e["Action"]
|
|
246
|
+
if action == "output" and output:
|
|
247
|
+
info["lines"] = (info["lines"] + [output.rstrip("\n")])[-50:]
|
|
248
|
+
elif action == "fail":
|
|
249
|
+
found = next((m[1] for x in info["lines"] if (m := GO_LINE.match(x))), None)
|
|
250
|
+
info["fail"] = info["fail"] or found or ""
|
|
251
|
+
info["detail"] = info["detail"] or (None, found, "\n".join(info["lines"]))
|
|
252
|
+
elif action == "pass":
|
|
253
|
+
info["passed"] = True
|
|
254
|
+
elif action == "skip":
|
|
255
|
+
info["skipped"] = True
|
|
256
|
+
if action in ("pass", "fail", "skip"):
|
|
257
|
+
info["ms"] = max(info["ms"], _millis(e.get("Elapsed") or 0, 1000))
|
|
258
|
+
if not events:
|
|
259
|
+
return None
|
|
260
|
+
# A parent fails whenever a subtest does, so it is left out then; failing with every subtest passing, it is its own.
|
|
261
|
+
failed_below = {p for p in parents if any(t.startswith(p + "/") and i["fail"] is not None for t, i in seen.items())}
|
|
262
|
+
out = _outcomes({tid: i for tid, i in seen.items()
|
|
263
|
+
if tid not in parents or (i["fail"] is not None and tid not in failed_below)})
|
|
264
|
+
return {**out, "seed": seed} if seed else out
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _json(data: bytes) -> dict | None:
|
|
268
|
+
try:
|
|
269
|
+
doc = json.loads(data)
|
|
270
|
+
except ValueError:
|
|
271
|
+
return _go(data) # an event stream, one JSON object per line
|
|
272
|
+
if not isinstance(doc, dict):
|
|
273
|
+
return None
|
|
274
|
+
if "results" in doc:
|
|
275
|
+
return _ctrf(doc)
|
|
276
|
+
if isinstance(doc.get("testResults"), list):
|
|
277
|
+
return _jest(doc)
|
|
278
|
+
return _go(data) if isinstance(doc.get("Action"), str) else None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def parse_report(name: str, data: bytes) -> dict | None:
|
|
282
|
+
"""One JUnit XML, TRX, CTRF JSON, Jest or Vitest JSON, or `go test -json` file; None when it is none of them or is
|
|
283
|
+
unsafe to read."""
|
|
284
|
+
head = data.lstrip()[:1]
|
|
285
|
+
low = name.lower()
|
|
286
|
+
if low.endswith((".xml", ".trx")) or head == b"<":
|
|
287
|
+
return _xml(data)
|
|
288
|
+
if low.endswith((".json", ".jsonl", ".ndjson")) or head == b"{":
|
|
289
|
+
return _json(data)
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def merge(parts: list[dict]) -> dict:
|
|
294
|
+
out = _empty()
|
|
295
|
+
for p in parts:
|
|
296
|
+
out["total"] += p["total"]
|
|
297
|
+
out["skipped"] += p["skipped"]
|
|
298
|
+
out["failed"] += p["failed"]
|
|
299
|
+
out["flaky"] += p["flaky"]
|
|
300
|
+
out["slow"] += p["slow"]
|
|
301
|
+
out["sk"] += p.get("sk") or []
|
|
302
|
+
out["all"] += p.get("all") or []
|
|
303
|
+
out["slow"] = sorted(out["slow"], key=lambda x: -x[1])[:SLOW_KEEP]
|
|
304
|
+
out["sk"] = out["sk"][:SKIP_KEEP]
|
|
305
|
+
out["failed"], out["flaky"], out["all"] = out["failed"][:MAX_ROWS], out["flaky"][:MAX_ROWS], out["all"][:MAX_ALL]
|
|
306
|
+
seed = next((p["seed"] for p in parts if p.get("seed")), None)
|
|
307
|
+
return {**out, "seed": seed} if seed else out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def parse_archive(data: bytes, max_file_bytes: int = 20_000_000, max_total_bytes: int = 200_000_000,
|
|
311
|
+
max_files: int = 500) -> dict:
|
|
312
|
+
"""Every report inside an Actions artifact zip, read in memory with size limits (nothing is extracted)."""
|
|
313
|
+
parts, skipped, total, cov = [], 0, 0, None
|
|
314
|
+
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
|
315
|
+
for info in z.infolist()[:max_files]:
|
|
316
|
+
if info.is_dir() or not info.filename.lower().endswith(REPORT_FILES + (".info", ".lcov")):
|
|
317
|
+
continue
|
|
318
|
+
# Sizes are checked before reading, so a zip bomb is skipped, not unpacked.
|
|
319
|
+
if info.file_size > max_file_bytes or total + info.file_size > max_total_bytes:
|
|
320
|
+
skipped += 1
|
|
321
|
+
continue
|
|
322
|
+
total += info.file_size
|
|
323
|
+
body = z.read(info)
|
|
324
|
+
found = coverage.parse(info.filename, body) if info.filename.lower().endswith((".xml", ".info", ".lcov")) else None
|
|
325
|
+
if found:
|
|
326
|
+
cov = _merge_cov(cov, found)
|
|
327
|
+
continue
|
|
328
|
+
if info.filename.lower().endswith((".info", ".lcov")):
|
|
329
|
+
continue
|
|
330
|
+
parsed = parse_report(info.filename, body)
|
|
331
|
+
if parsed is None:
|
|
332
|
+
if info.filename.lower().endswith((".xml", ".trx")):
|
|
333
|
+
skipped += 1
|
|
334
|
+
continue
|
|
335
|
+
parts.append(parsed)
|
|
336
|
+
# "cov" (coverage with its line hits) is read for patch coverage and never stored per report.
|
|
337
|
+
return {**merge(parts), "skipped_files": skipped, **({"cov": cov} if cov else {})}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _merge_cov(a: dict | None, b: dict) -> dict:
|
|
341
|
+
"""Two coverage files of one artifact as one: a line ran if it ran in either."""
|
|
342
|
+
if a is None:
|
|
343
|
+
return b
|
|
344
|
+
files = a["files"] # merged in place: copying everything again for each further file cost minutes on big reports
|
|
345
|
+
for path, hits in b["files"].items():
|
|
346
|
+
mine = files.setdefault(path, {})
|
|
347
|
+
for n, h in hits.items():
|
|
348
|
+
mine[n] = max(mine.get(n, 0), h)
|
|
349
|
+
lines = sum(len(h) for h in files.values())
|
|
350
|
+
covered = sum(1 for h in files.values() for x in h.values() if x > 0)
|
|
351
|
+
return {"format": a["format"] if a["format"] == b["format"] else "mixed", "lines": lines, "covered": covered, "files": files}
|