stackvitals 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mergescout/__init__.py +1 -0
- mergescout/archive/__init__.py +0 -0
- mergescout/archive/__main__.py +30 -0
- mergescout/archive/convert.py +83 -0
- mergescout/archive/events.py +55 -0
- mergescout/archive/manifest.py +45 -0
- mergescout/archive/stream.py +83 -0
- mergescout/bench/__init__.py +1 -0
- mergescout/bench/__main__.py +135 -0
- mergescout/bench/ci.py +257 -0
- mergescout/bench/deploys.py +115 -0
- mergescout/bench/matched.py +22 -0
- mergescout/bench/prior.py +42 -0
- mergescout/bench/report.py +230 -0
- mergescout/bench/shared.py +112 -0
- mergescout/bench/stars.py +57 -0
- mergescout/cli.py +89 -0
- mergescout/config.py +35 -0
- mergescout/crawl/__init__.py +0 -0
- mergescout/crawl/__main__.py +97 -0
- mergescout/crawl/chain.py +34 -0
- mergescout/crawl/fetch.py +146 -0
- mergescout/crawl/outcomes.py +69 -0
- mergescout/crawl/queries.py +250 -0
- mergescout/crawl/runner.py +176 -0
- mergescout/crawl/seed.py +77 -0
- mergescout/crawl/state.py +169 -0
- mergescout/crawl/writer.py +87 -0
- mergescout/discover/__init__.py +0 -0
- mergescout/discover/__main__.py +58 -0
- mergescout/discover/sweep.py +286 -0
- mergescout/github/__init__.py +0 -0
- mergescout/github/graphql.py +202 -0
- mergescout/github/identities.py +35 -0
- mergescout/github/releases.py +137 -0
- mergescout/labels.py +20 -0
- mergescout/mcp_server.py +228 -0
- mergescout/measure/__init__.py +0 -0
- mergescout/measure/__main__.py +75 -0
- mergescout/measure/api_cost.py +91 -0
- mergescout/measure/count_repos.py +30 -0
- mergescout/measure/jsonl.py +14 -0
- mergescout/measure/report.py +56 -0
- mergescout/measure/sample.py +37 -0
- mergescout/owners.py +22 -0
- mergescout/setup_check.py +64 -0
- mergescout/signals/__init__.py +0 -0
- mergescout/signals/__main__.py +96 -0
- mergescout/signals/bump_ledger.py +258 -0
- mergescout/signals/bumps.py +104 -0
- mergescout/signals/check.py +141 -0
- mergescout/signals/community_bench.py +149 -0
- mergescout/signals/delivery_bench.py +103 -0
- mergescout/signals/deps_map.py +261 -0
- mergescout/signals/deps_trends.py +117 -0
- mergescout/signals/evidence.py +94 -0
- mergescout/signals/exploits.py +43 -0
- mergescout/signals/flow_bench.py +258 -0
- mergescout/signals/identity.py +73 -0
- mergescout/signals/issues.py +76 -0
- mergescout/signals/labels.py +79 -0
- mergescout/signals/osv.py +232 -0
- mergescout/signals/owners_watch.py +80 -0
- mergescout/signals/repo_stats.py +96 -0
- mergescout/signals/rules.py +70 -0
- mergescout/signals/silver.py +33 -0
- mergescout/signals/top_packages.py +26 -0
- mergescout/signals/triage.py +71 -0
- mergescout/signals/triage_math.py +120 -0
- mergescout/signals/walls.py +91 -0
- mergescout/snapshot/__init__.py +0 -0
- mergescout/snapshot/__main__.py +215 -0
- mergescout/snapshot/build.py +147 -0
- mergescout/snapshot/publish.py +70 -0
- mergescout/snapshot/schema.py +34 -0
- mergescout/snapshot/score.py +30 -0
- mergescout/store/__init__.py +0 -0
- mergescout/store/__main__.py +46 -0
- mergescout/store/sync.py +148 -0
- mergescout/timeutil.py +11 -0
- mergescout/tool/__init__.py +0 -0
- mergescout/tool/action_pack.py +62 -0
- mergescout/tool/live.py +201 -0
- mergescout/tool/queries.py +293 -0
- mergescout/tool/refs.py +31 -0
- mergescout/tool/store.py +129 -0
- mergescout/tool/tracking.py +83 -0
- stackvitals/__init__.py +2 -0
- stackvitals/__main__.py +4 -0
- stackvitals/ci/__init__.py +1 -0
- stackvitals/ci/action.py +574 -0
- stackvitals/ci/analyze.py +577 -0
- stackvitals/ci/api.py +218 -0
- stackvitals/ci/automation.py +212 -0
- stackvitals/ci/bench.py +195 -0
- stackvitals/ci/burnin.py +102 -0
- stackvitals/ci/cli.py +570 -0
- stackvitals/ci/collect.py +239 -0
- stackvitals/ci/cost.py +107 -0
- stackvitals/ci/coverage.py +155 -0
- stackvitals/ci/feedback.py +699 -0
- stackvitals/ci/fingerprint.py +220 -0
- stackvitals/ci/gitlab.py +121 -0
- stackvitals/ci/gitlab_action.py +125 -0
- stackvitals/ci/impact.py +125 -0
- stackvitals/ci/investigate.py +59 -0
- stackvitals/ci/kinds.py +52 -0
- stackvitals/ci/monitors.py +105 -0
- stackvitals/ci/owners.py +116 -0
- stackvitals/ci/quarantine_runtime.py +90 -0
- stackvitals/ci/report.py +435 -0
- stackvitals/ci/retry.py +67 -0
- stackvitals/ci/selfheal.py +102 -0
- stackvitals/ci/shards.py +120 -0
- stackvitals/ci/share.py +59 -0
- stackvitals/ci/store.py +93 -0
- stackvitals/ci/testreports.py +351 -0
- stackvitals/ci/tickets.py +106 -0
- stackvitals/ci/track_record.json +11 -0
- stackvitals/ci/triage.py +130 -0
- stackvitals/ci/triggers.py +147 -0
- stackvitals/ci/upload.py +85 -0
- stackvitals/ci/validate.py +91 -0
- stackvitals/cli.py +111 -0
- stackvitals/codeowners.py +69 -0
- stackvitals/community/__init__.py +0 -0
- stackvitals/community/action.py +140 -0
- stackvitals/community/affiliation.py +51 -0
- stackvitals/community/analyze.py +507 -0
- stackvitals/community/build.py +56 -0
- stackvitals/community/chaoss.py +70 -0
- stackvitals/community/cli.py +79 -0
- stackvitals/community/collect.py +240 -0
- stackvitals/community/context.py +54 -0
- stackvitals/community/report.py +250 -0
- stackvitals/connectors/__init__.py +244 -0
- stackvitals/connectors/ai_spend.py +190 -0
- stackvitals/connectors/base.py +82 -0
- stackvitals/connectors/check.py +686 -0
- stackvitals/connectors/copilot.py +104 -0
- stackvitals/connectors/discord.py +103 -0
- stackvitals/connectors/discourse.py +117 -0
- stackvitals/connectors/incident_io.py +106 -0
- stackvitals/connectors/jira.py +210 -0
- stackvitals/connectors/linear.py +140 -0
- stackvitals/connectors/pagerduty.py +96 -0
- stackvitals/connectors/slack.py +110 -0
- stackvitals/connectors/sonar.py +77 -0
- stackvitals/connectors/store.py +120 -0
- stackvitals/connectors/tracker.py +108 -0
- stackvitals/connectors/writeback.py +105 -0
- stackvitals/contribute/__init__.py +1 -0
- stackvitals/contribute/brief.py +297 -0
- stackvitals/contribute/cli.py +64 -0
- stackvitals/core/__init__.py +1 -0
- stackvitals/core/actors.py +103 -0
- stackvitals/core/agent_ledger.py +120 -0
- stackvitals/core/agreements.py +95 -0
- stackvitals/core/ai_outcomes.py +276 -0
- stackvitals/core/alerts.py +224 -0
- stackvitals/core/appdata.py +112 -0
- stackvitals/core/bucket.py +190 -0
- stackvitals/core/bugs.py +43 -0
- stackvitals/core/calendar.py +34 -0
- stackvitals/core/capex.py +57 -0
- stackvitals/core/change_risk.py +181 -0
- stackvitals/core/changes.py +133 -0
- stackvitals/core/chat.py +143 -0
- stackvitals/core/config.py +373 -0
- stackvitals/core/dataflow.py +168 -0
- stackvitals/core/decisions.py +275 -0
- stackvitals/core/delivery.py +954 -0
- stackvitals/core/delivery_peers.py +98 -0
- stackvitals/core/delivery_report.py +429 -0
- stackvitals/core/deliverycli.py +147 -0
- stackvitals/core/devlake_dora.sql +485 -0
- stackvitals/core/digest.py +333 -0
- stackvitals/core/explaincli.py +107 -0
- stackvitals/core/explore.py +296 -0
- stackvitals/core/export.py +342 -0
- stackvitals/core/exportcli.py +117 -0
- stackvitals/core/findings.py +350 -0
- stackvitals/core/health.py +94 -0
- stackvitals/core/identity.py +82 -0
- stackvitals/core/insights.py +365 -0
- stackvitals/core/insightscli.py +43 -0
- stackvitals/core/installdoc.py +69 -0
- stackvitals/core/investment.py +187 -0
- stackvitals/core/joins.py +43 -0
- stackvitals/core/llmsdoc.py +40 -0
- stackvitals/core/merge_queue.py +70 -0
- stackvitals/core/metrics.py +109 -0
- stackvitals/core/model.py +285 -0
- stackvitals/core/projectcli.py +66 -0
- stackvitals/core/propose.py +86 -0
- stackvitals/core/pulse.py +85 -0
- stackvitals/core/recorded.py +145 -0
- stackvitals/core/releases.py +39 -0
- stackvitals/core/requests.py +528 -0
- stackvitals/core/resync.py +35 -0
- stackvitals/core/schema.py +205 -0
- stackvitals/core/services.py +203 -0
- stackvitals/core/settings.py +96 -0
- stackvitals/core/sqlcli.py +111 -0
- stackvitals/core/stats.py +222 -0
- stackvitals/core/store.py +125 -0
- stackvitals/core/summary.py +104 -0
- stackvitals/core/time.py +29 -0
- stackvitals/core/tomledit.py +149 -0
- stackvitals/core/uicli.py +138 -0
- stackvitals/core/webapp.py +79 -0
- stackvitals/core/what_changed.py +76 -0
- stackvitals/core/workspace.py +194 -0
- stackvitals/deps/__init__.py +1 -0
- stackvitals/deps/action.py +299 -0
- stackvitals/deps/actions_vulns.py +89 -0
- stackvitals/deps/cli.py +77 -0
- stackvitals/deps/dossier.py +341 -0
- stackvitals/deps/evidence.py +44 -0
- stackvitals/deps/fixplan.py +40 -0
- stackvitals/deps/guarddog.py +32 -0
- stackvitals/deps/hygiene.py +47 -0
- stackvitals/deps/imports.py +113 -0
- stackvitals/deps/lockfiles.py +198 -0
- stackvitals/deps/lookalike.py +80 -0
- stackvitals/deps/reach.py +59 -0
- stackvitals/deps/release.py +110 -0
- stackvitals/deps/runtimes.py +161 -0
- stackvitals/deps/supply.py +454 -0
- stackvitals/deps/vulns.py +588 -0
- stackvitals/editors/__init__.py +40 -0
- stackvitals/editors/vscode/extension.js +101 -0
- stackvitals/editors/vscode/marks.js +92 -0
- stackvitals/editors/vscode/package.json +26 -0
- stackvitals/flow/__init__.py +1 -0
- stackvitals/flow/action.py +178 -0
- stackvitals/flow/analyze.py +418 -0
- stackvitals/flow/automate.py +72 -0
- stackvitals/flow/build.py +46 -0
- stackvitals/flow/cli.py +93 -0
- stackvitals/flow/code_hosts.py +146 -0
- stackvitals/flow/collect.py +451 -0
- stackvitals/flow/gitlab_mr.py +75 -0
- stackvitals/flow/knowledge.py +202 -0
- stackvitals/flow/peers.py +90 -0
- stackvitals/flow/preflight.py +210 -0
- stackvitals/flow/report.py +253 -0
- stackvitals/flow/rules.py +135 -0
- stackvitals/flow/store.py +108 -0
- stackvitals/flow/survival.py +84 -0
- stackvitals/flow/triage.py +62 -0
- stackvitals/mcp_server.py +670 -0
- stackvitals/portal/__init__.py +0 -0
- stackvitals/portal/action.py +205 -0
- stackvitals/portal/batch.py +80 -0
- stackvitals/portal/campaigns.py +104 -0
- stackvitals/portal/catalog.py +104 -0
- stackvitals/portal/checks.py +63 -0
- stackvitals/portal/cli.py +172 -0
- stackvitals/portal/descriptor.py +105 -0
- stackvitals/portal/export.py +43 -0
- stackvitals/portal/github.py +48 -0
- stackvitals/portal/golden.py +95 -0
- stackvitals/portal/history.py +30 -0
- stackvitals/portal/inventory.py +264 -0
- stackvitals/portal/owners.py +78 -0
- stackvitals/portal/pages.py +153 -0
- stackvitals/portal/rollup.py +32 -0
- stackvitals/portal/scorecard.py +366 -0
- stackvitals/security/__init__.py +2 -0
- stackvitals/security/probes.py +154 -0
- stackvitals/security/repo_checks.py +132 -0
- stackvitals/security/workflows.py +111 -0
- stackvitals/shell.py +107 -0
- stackvitals/site.py +51 -0
- stackvitals/web/app/LICENSES.md +428 -0
- stackvitals/web/app/boot.js +1 -0
- stackvitals/web/app/chunks/AddRepo-KRPZXWCY.js +1 -0
- stackvitals/web/app/chunks/SetupRunning-XWFQAKRZ.js +1 -0
- stackvitals/web/app/chunks/accuracy-data-OQTDXI7Y.js +1 -0
- stackvitals/web/app/chunks/agents-data-BAL6QAOF.js +11 -0
- stackvitals/web/app/chunks/chunk-24LFKFZ6.js +1 -0
- stackvitals/web/app/chunks/chunk-3G4SQNUC.js +1 -0
- stackvitals/web/app/chunks/chunk-3IXWEOIJ.js +154 -0
- stackvitals/web/app/chunks/chunk-3ROQFHPV.js +1 -0
- stackvitals/web/app/chunks/chunk-45PGAQRO.js +8 -0
- stackvitals/web/app/chunks/chunk-4H53OYLG.js +1 -0
- stackvitals/web/app/chunks/chunk-4LUI7O5P.js +1 -0
- stackvitals/web/app/chunks/chunk-4SQPQ5TB.js +1 -0
- stackvitals/web/app/chunks/chunk-4WKQTWUC.js +2 -0
- stackvitals/web/app/chunks/chunk-576YHDEK.js +8 -0
- stackvitals/web/app/chunks/chunk-6BDBXA6V.js +1 -0
- stackvitals/web/app/chunks/chunk-6RLVD2DT.js +1 -0
- stackvitals/web/app/chunks/chunk-763NH7PP.js +1 -0
- stackvitals/web/app/chunks/chunk-76WAVKUG.js +1 -0
- stackvitals/web/app/chunks/chunk-7II2BD5F.js +1 -0
- stackvitals/web/app/chunks/chunk-7TJCBOAY.js +1 -0
- stackvitals/web/app/chunks/chunk-ASYNLVOP.js +1 -0
- stackvitals/web/app/chunks/chunk-CTKDYZMG.js +1 -0
- stackvitals/web/app/chunks/chunk-CVB26NMC.js +1 -0
- stackvitals/web/app/chunks/chunk-G7NGB5UX.js +1 -0
- stackvitals/web/app/chunks/chunk-GPVLJRZD.js +1 -0
- stackvitals/web/app/chunks/chunk-GQYFN4PE.js +75 -0
- stackvitals/web/app/chunks/chunk-GT4EAKAO.js +2 -0
- stackvitals/web/app/chunks/chunk-IYMBFPCV.js +1 -0
- stackvitals/web/app/chunks/chunk-LAAGCPU3.js +1 -0
- stackvitals/web/app/chunks/chunk-MAPYGK3P.js +1 -0
- stackvitals/web/app/chunks/chunk-P5TXDD45.js +1 -0
- stackvitals/web/app/chunks/chunk-PABICGBL.js +1 -0
- stackvitals/web/app/chunks/chunk-RPXHAG6F.js +1 -0
- stackvitals/web/app/chunks/chunk-RQZPSJ2Y.js +1 -0
- stackvitals/web/app/chunks/chunk-S2ZBMEVA.js +1 -0
- stackvitals/web/app/chunks/chunk-S654BBEP.js +6 -0
- stackvitals/web/app/chunks/chunk-T7QVTNXF.js +1 -0
- stackvitals/web/app/chunks/chunk-THFUOCPC.js +273 -0
- stackvitals/web/app/chunks/chunk-TO4VVRZ7.js +1 -0
- stackvitals/web/app/chunks/chunk-VQ5YXO54.js +1 -0
- stackvitals/web/app/chunks/chunk-VWKSMFWJ.js +1 -0
- stackvitals/web/app/chunks/chunk-Y6LUXDM6.js +1 -0
- stackvitals/web/app/chunks/chunk-YTANGYYH.js +1 -0
- stackvitals/web/app/chunks/connect-KONYWYTT.js +1 -0
- stackvitals/web/app/chunks/connections-data-2DYS4UVW.js +32 -0
- stackvitals/web/app/chunks/logos-N5IINUAM.js +1 -0
- stackvitals/web/app/chunks/more-KMJMJQD3.js +10 -0
- stackvitals/web/app/chunks/more-PC6FVS3P.js +1 -0
- stackvitals/web/app/chunks/more-RCIVCUDE.js +2 -0
- stackvitals/web/app/chunks/more-ZKKRY6NO.css +1 -0
- stackvitals/web/app/chunks/sealbox-35YL4EUH.js +3 -0
- stackvitals/web/app/chunks/session-DYYTOE2K.js +1 -0
- stackvitals/web/app/chunks/settingsApply-2HRJEZYM.js +1 -0
- stackvitals/web/app/chunks/settingsReview-GX2FZQ4M.js +3 -0
- stackvitals/web/app/chunks/uPlot.esm-OIWT3AHJ.js +3 -0
- stackvitals/web/app/demo/ci/impact.json +1 -0
- stackvitals/web/app/demo/ci/quarantine.json +1 -0
- stackvitals/web/app/demo/ci/tickets.json +1 -0
- stackvitals/web/app/demo/mobile/alerts.json +1 -0
- stackvitals/web/app/demo/mobile/app.json +1 -0
- stackvitals/web/app/demo/mobile/community.json +1 -0
- stackvitals/web/app/demo/mobile/connectors.json +1 -0
- stackvitals/web/app/demo/mobile/data.json +1 -0
- stackvitals/web/app/demo/mobile/decisions.json +1 -0
- stackvitals/web/app/demo/mobile/delivery.json +1 -0
- stackvitals/web/app/demo/mobile/deps.json +1 -0
- stackvitals/web/app/demo/mobile/flow.json +1 -0
- stackvitals/web/app/demo/mobile/health.json +1 -0
- stackvitals/web/app/demo/mobile/insights.json +1 -0
- stackvitals/web/app/demo/mobile/pulse.json +1 -0
- stackvitals/web/app/demo/mobile/security.json +1 -0
- stackvitals/web/app/demo/mobile/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/mobile/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/mobile/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/mobile/summary/manifest.json +1 -0
- stackvitals/web/app/demo/mobile/summary/prs.jsonl +19 -0
- stackvitals/web/app/demo/mobile/summary/tests.jsonl +1 -0
- stackvitals/web/app/demo/mobile/supply.json +1 -0
- stackvitals/web/app/demo/mobile/vulns.json +1 -0
- stackvitals/web/app/demo/payments-api/alerts.json +1 -0
- stackvitals/web/app/demo/payments-api/app.json +1 -0
- stackvitals/web/app/demo/payments-api/community.json +1 -0
- stackvitals/web/app/demo/payments-api/connectors.json +1 -0
- stackvitals/web/app/demo/payments-api/data.json +1 -0
- stackvitals/web/app/demo/payments-api/decisions.json +1 -0
- stackvitals/web/app/demo/payments-api/delivery.json +1 -0
- stackvitals/web/app/demo/payments-api/deps.json +1 -0
- stackvitals/web/app/demo/payments-api/flow.json +1 -0
- stackvitals/web/app/demo/payments-api/health.json +1 -0
- stackvitals/web/app/demo/payments-api/insights.json +1 -0
- stackvitals/web/app/demo/payments-api/pulse.json +1 -0
- stackvitals/web/app/demo/payments-api/security.json +1 -0
- stackvitals/web/app/demo/payments-api/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/payments-api/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/payments-api/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/payments-api/summary/manifest.json +1 -0
- stackvitals/web/app/demo/payments-api/summary/prs.jsonl +22 -0
- stackvitals/web/app/demo/payments-api/summary/tests.jsonl +3 -0
- stackvitals/web/app/demo/payments-api/supply.json +1 -0
- stackvitals/web/app/demo/payments-api/vulns.json +1 -0
- stackvitals/web/app/demo/portal.json +1 -0
- stackvitals/web/app/demo/settings.json +1 -0
- stackvitals/web/app/demo/web-app/alerts.json +1 -0
- stackvitals/web/app/demo/web-app/app.json +1 -0
- stackvitals/web/app/demo/web-app/community.json +1 -0
- stackvitals/web/app/demo/web-app/connectors.json +1 -0
- stackvitals/web/app/demo/web-app/data.json +1 -0
- stackvitals/web/app/demo/web-app/decisions.json +1 -0
- stackvitals/web/app/demo/web-app/delivery.json +1 -0
- stackvitals/web/app/demo/web-app/deps.json +1 -0
- stackvitals/web/app/demo/web-app/flow.json +1 -0
- stackvitals/web/app/demo/web-app/health.json +1 -0
- stackvitals/web/app/demo/web-app/insights.json +1 -0
- stackvitals/web/app/demo/web-app/pulse.json +1 -0
- stackvitals/web/app/demo/web-app/security.json +1 -0
- stackvitals/web/app/demo/web-app/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/web-app/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/web-app/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/web-app/summary/manifest.json +1 -0
- stackvitals/web/app/demo/web-app/summary/prs.jsonl +18 -0
- stackvitals/web/app/demo/web-app/summary/tests.jsonl +2 -0
- stackvitals/web/app/demo/web-app/supply.json +1 -0
- stackvitals/web/app/demo/web-app/vulns.json +1 -0
- stackvitals/web/app/fonts/plex-mono-400.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-400.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-600.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-condensed-700.woff2 +0 -0
- stackvitals/web/app/help/accuracy.json +1 -0
- stackvitals/web/app/help/connectors.json +1 -0
- stackvitals/web/app/help/getting-started.json +1 -0
- stackvitals/web/app/help/index.json +1 -0
- stackvitals/web/app/help/modules.json +1 -0
- stackvitals/web/app/help/privacy-and-security.json +1 -0
- stackvitals/web/app/help/reference.json +1 -0
- stackvitals/web/app/main.css +1 -0
- stackvitals/web/app/main.js +1 -0
- stackvitals/web/app/pages/about.css +1 -0
- stackvitals/web/app/pages/about.js +1 -0
- stackvitals/web/app/pages/activity.css +1 -0
- stackvitals/web/app/pages/activity.js +1 -0
- stackvitals/web/app/pages/agents.css +1 -0
- stackvitals/web/app/pages/agents.js +2 -0
- stackvitals/web/app/pages/ci.css +1 -0
- stackvitals/web/app/pages/ci.js +1 -0
- stackvitals/web/app/pages/community.css +1 -0
- stackvitals/web/app/pages/community.js +1 -0
- stackvitals/web/app/pages/connections.css +1 -0
- stackvitals/web/app/pages/connections.js +6 -0
- stackvitals/web/app/pages/delivery.css +1 -0
- stackvitals/web/app/pages/delivery.js +45 -0
- stackvitals/web/app/pages/deps.css +1 -0
- stackvitals/web/app/pages/deps.js +7 -0
- stackvitals/web/app/pages/explore.css +1 -0
- stackvitals/web/app/pages/explore.js +7 -0
- stackvitals/web/app/pages/health.css +1 -0
- stackvitals/web/app/pages/health.js +1 -0
- stackvitals/web/app/pages/help.css +1 -0
- stackvitals/web/app/pages/help.js +6 -0
- stackvitals/web/app/pages/insights.css +1 -0
- stackvitals/web/app/pages/insights.js +1 -0
- stackvitals/web/app/pages/investment.css +1 -0
- stackvitals/web/app/pages/investment.js +3 -0
- stackvitals/web/app/pages/overview.css +1 -0
- stackvitals/web/app/pages/overview.js +1 -0
- stackvitals/web/app/pages/portal.css +1 -0
- stackvitals/web/app/pages/portal.js +51 -0
- stackvitals/web/app/pages/prs.css +1 -0
- stackvitals/web/app/pages/prs.js +4 -0
- stackvitals/web/app/pages/settings.css +1 -0
- stackvitals/web/app/pages/settings.js +1 -0
- stackvitals/web/app/pages/setup.css +1 -0
- stackvitals/web/app/pages/setup.js +2 -0
- stackvitals/web/app/pages/standing.css +1 -0
- stackvitals/web/app/pages/standing.js +1 -0
- stackvitals/web/app/pages/welcome-sheet.js +1 -0
- stackvitals/web/app/pages/welcome.js +1 -0
- stackvitals/web/app/settings-workflow.json +1 -0
- stackvitals/web/index.html +34 -0
- stackvitals/web/site/index.html +108 -0
- stackvitals/web/site/integrations.html +15 -0
- stackvitals/web/site/setup.html +15 -0
- stackvitals/web/site/site.css +68 -0
- stackvitals-0.1.0.dist-info/METADATA +134 -0
- stackvitals-0.1.0.dist-info/RECORD +465 -0
- stackvitals-0.1.0.dist-info/WHEEL +4 -0
- stackvitals-0.1.0.dist-info/entry_points.txt +3 -0
- stackvitals-0.1.0.dist-info/licenses/LICENSE +202 -0
- stackvitals-0.1.0.dist-info/licenses/NOTICE +16 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import re
|
|
3
|
+
from bisect import bisect_right
|
|
4
|
+
from collections import Counter, defaultdict
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from stackvitals.ci import monitors
|
|
8
|
+
from stackvitals.ci.kinds import kind_of
|
|
9
|
+
from stackvitals.ci.bench import push_stats, push_stats_by, push_window
|
|
10
|
+
from stackvitals.ci.triggers import PR_EVENTS
|
|
11
|
+
from stackvitals.core import changes
|
|
12
|
+
from stackvitals.core.stats import fisher
|
|
13
|
+
from stackvitals.core.stats import median as _median
|
|
14
|
+
from stackvitals.core.stats import percentile as _pct
|
|
15
|
+
from stackvitals.core.stats import wilson
|
|
16
|
+
from stackvitals.core.time import parse as _t
|
|
17
|
+
from stackvitals.core.time import secs as _secs
|
|
18
|
+
from stackvitals.core.time import week_of
|
|
19
|
+
from stackvitals.core.config import DEFAULTS
|
|
20
|
+
|
|
21
|
+
FAIL = {"failure", "timed_out"}
|
|
22
|
+
PASS = {"success"}
|
|
23
|
+
# Detection rules, the same shape the paid tools describe: on after 2 flakes, off after 2 quiet weeks.
|
|
24
|
+
FLAKY_MIN_EVENTS = 2
|
|
25
|
+
QUIET_DAYS = 14
|
|
26
|
+
QUIET_RUNS = 10
|
|
27
|
+
OUTAGE_MIN_JOBS = 5
|
|
28
|
+
OUTAGE_SHARE = 0.8
|
|
29
|
+
BROKEN_RUNS = 3
|
|
30
|
+
SAME_CODE_EVENTS = {"push", "pull_request", "pull_request_target", "merge_group"}
|
|
31
|
+
# States the dashboard lists, so each gets a history page (at most MAX_HISTORY per kind, the worst first).
|
|
32
|
+
SHOWN = {"flaky", "suspect", "broken"}
|
|
33
|
+
MAX_HISTORY = 100
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _os(labels: list[str]) -> str:
|
|
37
|
+
text = " ".join(labels).lower()
|
|
38
|
+
for name in ("windows", "macos"):
|
|
39
|
+
if name in text:
|
|
40
|
+
return name
|
|
41
|
+
return "linux" if ("ubuntu" in text or "linux" in text) else "other"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _failed_step(job: dict) -> str:
|
|
45
|
+
return next((s[1] for s in job.get("steps") or [] if s[2] in FAIL), "unknown step")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# A flake's start is called clear only with this many clean runs before it and a difference chance can't explain.
|
|
49
|
+
BEGAN_MIN_RUNS = 20
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _began(times: list, flakes: list, pushes: list) -> dict | None:
|
|
53
|
+
"""When a job or test began to flake: its first flake, its runs before and after it, Fisher's test of "never
|
|
54
|
+
before, often after" against chance, and the commits that reached the default branch in the day before it."""
|
|
55
|
+
if not flakes:
|
|
56
|
+
return None
|
|
57
|
+
first = min(flakes)
|
|
58
|
+
before = sum(1 for t in times if t < first)
|
|
59
|
+
after = max(len(times) - before, len(flakes))
|
|
60
|
+
p = fisher(0, before, len(flakes), after)
|
|
61
|
+
landed = []
|
|
62
|
+
for t, sha in pushes:
|
|
63
|
+
if first - timedelta(days=1) <= t <= first and sha not in landed:
|
|
64
|
+
landed.append(sha)
|
|
65
|
+
return {"first": first.strftime("%Y-%m-%dT%H:%M:%SZ"), "runs_before": before, "runs_after": after,
|
|
66
|
+
"flakes": len(flakes), "p": round(p, 4), "clear": before >= BEGAN_MIN_RUNS and p < 0.05, "landed": landed[-5:]}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _pushes(runs: dict, final_att: dict, default_branch: str | None) -> list:
|
|
70
|
+
"""(when, commit) of each push to the default branch, oldest first."""
|
|
71
|
+
return sorted((_t(r["cr"]), r["sha"]) for (rid, att), r in runs.items() if att == final_att[rid] and r.get("sha")
|
|
72
|
+
and r.get("ev") == "push" and default_branch is not None and r.get("br") == default_branch)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _state(ev_all: list, ev_win: list, runs_since: int, broken: bool, now: datetime) -> str:
|
|
76
|
+
if broken:
|
|
77
|
+
return "broken"
|
|
78
|
+
last = ev_all[-1][0] if ev_all else None
|
|
79
|
+
if len(ev_win) >= FLAKY_MIN_EVENTS or (len(ev_all) >= FLAKY_MIN_EVENTS and (now - last).days < QUIET_DAYS):
|
|
80
|
+
return "flaky"
|
|
81
|
+
if len(ev_all) >= FLAKY_MIN_EVENTS and runs_since >= QUIET_RUNS:
|
|
82
|
+
return "fixed"
|
|
83
|
+
return "suspect" if ev_win else "ok"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _tests(data: dict, runs: dict, final_att: dict, since: datetime, now: datetime, default_branch: str | None,
|
|
87
|
+
config: dict | None = None, previous: dict | None = None) -> tuple[list, list, int, dict]:
|
|
88
|
+
"""Flaky, broken and slow tests from the test reports found in run artifacts."""
|
|
89
|
+
# Run and job ids inside artifact names (6+ digits) would make every run a new suite; shard numbers stay.
|
|
90
|
+
reports = [{**r, "name": re.sub(r"\d{6,}", "#", r.get("name") or "")} for r in data.get("tests") or []
|
|
91
|
+
if (r["run"], r["att"]) in runs]
|
|
92
|
+
when = {id(r): _t(runs[(r["run"], r["att"])]["cr"]) for r in reports}
|
|
93
|
+
# Indexed once: the loops below used to rebuild these sets for every test id and report.
|
|
94
|
+
failed_ids = {id(r): {f[0] for f in r["failed"]} for r in reports}
|
|
95
|
+
outage = {id(r) for r in reports if r["total"] >= 10 and len(r["failed"]) / r["total"] >= OUTAGE_SHARE}
|
|
96
|
+
events, messages, suite = defaultdict(list), defaultdict(Counter), {}
|
|
97
|
+
by_attempt = defaultdict(list)
|
|
98
|
+
for r in reports:
|
|
99
|
+
by_attempt[(r["run"], r["att"])].append(r)
|
|
100
|
+
for f in r["failed"]:
|
|
101
|
+
tid, msg = f[0], f[2]
|
|
102
|
+
messages[tid][msg] += 1
|
|
103
|
+
suite.setdefault(tid, r["name"])
|
|
104
|
+
for tid in r["flaky"]:
|
|
105
|
+
suite.setdefault(tid, r["name"])
|
|
106
|
+
# Retried and passed inside one job: the report itself proves the flake.
|
|
107
|
+
if id(r) not in outage:
|
|
108
|
+
events[tid].append((when[id(r)], "in_job", runs[(r["run"], r["att"])].get("pr") or [],
|
|
109
|
+
{"run": r["run"], "att": r["att"], "kind": "in_job", "variant": r["name"]}))
|
|
110
|
+
# A later attempt of the same run with the same report passed the test: one flake per run, like jobs.
|
|
111
|
+
counted = set()
|
|
112
|
+
for rid, last in final_att.items():
|
|
113
|
+
for att in range(1, last):
|
|
114
|
+
for r in by_attempt.get((rid, att), []):
|
|
115
|
+
if id(r) in outage:
|
|
116
|
+
continue
|
|
117
|
+
later = [o for a in range(att + 1, last + 1) for o in by_attempt.get((rid, a), []) if o["name"] == r["name"]]
|
|
118
|
+
for f in r["failed"]:
|
|
119
|
+
tid = f[0]
|
|
120
|
+
if (rid, tid) not in counted and any(tid not in {f[0] for f in o["failed"]} for o in later):
|
|
121
|
+
counted.add((rid, tid))
|
|
122
|
+
events[tid].append((when[id(r)], "retry", runs[(rid, att)].get("pr") or [],
|
|
123
|
+
{"run": rid, "att": att, "kind": "retry", "variant": r["name"]}))
|
|
124
|
+
# The same code passed the test in another push or PR run.
|
|
125
|
+
finals = defaultdict(list)
|
|
126
|
+
for r in reports:
|
|
127
|
+
run = runs[(r["run"], r["att"])]
|
|
128
|
+
if r["att"] == final_att[r["run"]] and run.get("tree") and run.get("ev") in SAME_CODE_EVENTS:
|
|
129
|
+
# Same branch or tag too: workflows often act on the ref (version checks on tags, deploys on main).
|
|
130
|
+
finals[(run["tree"], run["ev"], run.get("br"), r["name"])].append(r)
|
|
131
|
+
for group in finals.values():
|
|
132
|
+
failed_by = defaultdict(list)
|
|
133
|
+
for r in group:
|
|
134
|
+
if id(r) not in outage:
|
|
135
|
+
for f in r["failed"]:
|
|
136
|
+
failed_by[f[0]].append(r)
|
|
137
|
+
for tid, bad in failed_by.items():
|
|
138
|
+
if any(tid not in failed_ids[id(r)] for r in group):
|
|
139
|
+
r = max(bad, key=lambda x: when[id(x)])
|
|
140
|
+
events[tid].append((when[id(r)], "flip", runs[(r["run"], r["att"])].get("pr") or [],
|
|
141
|
+
{"run": r["run"], "att": r["att"], "kind": "flip", "variant": r["name"]}))
|
|
142
|
+
final_reports = sorted((r for r in reports if r["att"] == final_att[r["run"]]), key=lambda r: when[id(r)])
|
|
143
|
+
# Per suite, once: its reports, the ones in the window, the ones on the default branch, and their times in order.
|
|
144
|
+
by_name, win_by_name, default_by_name, times_by_name = defaultdict(list), {}, {}, {}
|
|
145
|
+
for r in final_reports:
|
|
146
|
+
by_name[r["name"]].append(r)
|
|
147
|
+
for name, mine in by_name.items():
|
|
148
|
+
win_by_name[name] = [r for r in mine if when[id(r)] >= since]
|
|
149
|
+
default_by_name[name] = [r for r in mine if default_branch is None or runs[(r["run"], r["att"])].get("br") == default_branch]
|
|
150
|
+
times_by_name[name] = [when[id(r)] for r in mine]
|
|
151
|
+
fail_win = Counter()
|
|
152
|
+
pass_ms, fail_ms = defaultdict(list), defaultdict(list)
|
|
153
|
+
for r in final_reports:
|
|
154
|
+
if when[id(r)] >= since:
|
|
155
|
+
for tid in failed_ids[id(r)]:
|
|
156
|
+
if suite.get(tid) == r["name"]:
|
|
157
|
+
fail_win[tid] += 1
|
|
158
|
+
for tid, ms in r["slow"]:
|
|
159
|
+
if tid not in failed_ids[id(r)]:
|
|
160
|
+
pass_ms[tid].append(ms)
|
|
161
|
+
for f in r["failed"]:
|
|
162
|
+
if len(f) > 6 and f[6] is not None:
|
|
163
|
+
fail_ms[f[0]].append(f[6])
|
|
164
|
+
cfg = config or DEFAULTS
|
|
165
|
+
mon = cfg["ci"]["monitors"]
|
|
166
|
+
# The monitors look at the newest default-branch results only, so big suites stay fast.
|
|
167
|
+
hist_n = max(int(mon["failure_rate"]["window_runs"]), int(mon["broken"]["window_runs"]), int(mon["broken"]["streak"]), 1)
|
|
168
|
+
out, history, pushes = [], {}, _pushes(runs, final_att, default_branch)
|
|
169
|
+
for tid in sorted(set(messages) | set(events)):
|
|
170
|
+
name = suite.get(tid)
|
|
171
|
+
mine, win, default = by_name.get(name, []), win_by_name.get(name, []), default_by_name.get(name, [])
|
|
172
|
+
ev_all = sorted(events.get(tid, []), key=lambda e: e[0])
|
|
173
|
+
ev_win = [e for e in ev_all if e[0] >= since]
|
|
174
|
+
last = ev_all[-1][0] if ev_all else None
|
|
175
|
+
runs_since = len(mine) - bisect_right(times_by_name.get(name, []), last) if last else 0
|
|
176
|
+
hist = [(when[id(r)], tid in failed_ids[id(r)]) for r in default[-hist_n:]]
|
|
177
|
+
status = monitors.evaluate(ev_all, ev_win, hist, runs_since, now, cfg, (previous or {}).get(tid),
|
|
178
|
+
pass_ms.get(tid, ()), fail_ms.get(tid, ()), tid)
|
|
179
|
+
lo, hi = wilson(len(ev_win), len(win))
|
|
180
|
+
out.append({"id": tid, "suite": name, "runs": len(win),
|
|
181
|
+
"workflow": runs[(mine[-1]["run"], mine[-1]["att"])].get("path") if mine else None,
|
|
182
|
+
"failures": fail_win[tid],
|
|
183
|
+
"flake_events": len(ev_win), "flake_rate": round(len(ev_win) / len(win), 4) if win else 0.0,
|
|
184
|
+
"rate_low": round(lo, 4), "rate_high": round(hi, 4),
|
|
185
|
+
"state": status["state"], "monitors": status["monitors"], "labels": status["labels"],
|
|
186
|
+
"since": status["since"], "failure_rate": status["failure_rate"],
|
|
187
|
+
**({"muted_until": status["muted_until"]} if status.get("muted_until") else {}),
|
|
188
|
+
"last_flake": last.strftime("%Y-%m-%dT%H:%M:%SZ") if last else None,
|
|
189
|
+
"message": messages[tid].most_common(1)[0][0] if messages[tid] else None,
|
|
190
|
+
# What kind of flake its usual failure looks like (timing, network, ...), only once it has flaked.
|
|
191
|
+
"flake": kind_of(messages[tid].most_common(1)[0][0]) if messages[tid] and ev_all else None,
|
|
192
|
+
"prs_affected": sorted({p for e in ev_win for p in e[2]}),
|
|
193
|
+
# Which report (an OS or version in a matrix) the window's flakes came from.
|
|
194
|
+
"variants": dict(Counter(e[3].get("variant") for e in ev_win if e[3].get("variant")).most_common(5)),
|
|
195
|
+
# The newest runs that prove it, so anyone can open them.
|
|
196
|
+
"evidence": [{k: v for k, v in e[3].items() if k != "variant"} for e in ev_all[-5:]],
|
|
197
|
+
# The window's attempts it flaked in, for its cost (taken off before the page is written).
|
|
198
|
+
"_attempts": sorted({(e[3]["run"], e[3]["att"]) for e in ev_win if e[3].get("run")})[-50:],
|
|
199
|
+
"began": _began(times_by_name.get(name, []), [e[0] for e in ev_all], pushes)})
|
|
200
|
+
# History pages only for the tests that get one (the worst MAX_HISTORY): building rows for every shown test cost
|
|
201
|
+
# tests x reports on big suites.
|
|
202
|
+
order = {"flaky": 0, "broken": 1, "suspect": 2}
|
|
203
|
+
worst = sorted((t for t in out if t["state"] in SHOWN), key=lambda t: (order.get(t["state"], 3), -t["flake_events"]))
|
|
204
|
+
for t in worst[:MAX_HISTORY]:
|
|
205
|
+
tid = t["id"]
|
|
206
|
+
flaked = {e[3]["run"]: e[3] for e in sorted(events.get(tid, []), key=lambda e: e[0])}
|
|
207
|
+
history[tid] = [_row(runs[(r["run"], r["att"])], flaked, tid not in failed_ids[id(r)]) for r in by_name.get(suite.get(tid), [])]
|
|
208
|
+
durations = defaultdict(list)
|
|
209
|
+
for r in final_reports:
|
|
210
|
+
if when[id(r)] >= since:
|
|
211
|
+
for tid, ms in r["slow"]:
|
|
212
|
+
durations[tid].append((when[id(r)], ms))
|
|
213
|
+
slow = []
|
|
214
|
+
for tid, items in durations.items():
|
|
215
|
+
values = [ms for _, ms in items]
|
|
216
|
+
recent = [ms for t, ms in items if t >= now - timedelta(days=7)]
|
|
217
|
+
before = [ms for t, ms in items if t < now - timedelta(days=7)]
|
|
218
|
+
slower = None
|
|
219
|
+
if len(recent) >= 5 and len(before) >= 5:
|
|
220
|
+
a, b = _median(before), _median(recent)
|
|
221
|
+
if b >= a * 1.25 and b - a >= 5000:
|
|
222
|
+
slower = {"before_ms": a, "after_ms": b, "pct": round((b / a - 1) * 100)}
|
|
223
|
+
slow.append({"id": tid, "median_ms": _median(values), "p95_ms": _pct(values, 0.95), "samples": len(values),
|
|
224
|
+
"slower": slower, "trend": _trend(items)})
|
|
225
|
+
slow.sort(key=lambda s: -(s["median_ms"] or 0))
|
|
226
|
+
outages = len({r["run"] for r in reports if id(r) in outage and when[id(r)] >= since})
|
|
227
|
+
return out, slow[:20], outages, history
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
MAX_GROUPS = 50
|
|
231
|
+
NEW_DAYS, NEW_SHOWN, SKIP_SHOWN, NEW_SKIP_DAYS = 14, 50, 100, 7
|
|
232
|
+
# Runs of this repo's own branch: pull request events (forks too) can carry a branch named like the default one.
|
|
233
|
+
OWN_EVENTS = {"push", "schedule", "workflow_dispatch", None}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _trend(items) -> list:
|
|
237
|
+
"""Daily medians of (time, value) pairs, oldest first."""
|
|
238
|
+
by_day = defaultdict(list)
|
|
239
|
+
for when, value in items:
|
|
240
|
+
by_day[when.strftime("%Y-%m-%d")].append(value)
|
|
241
|
+
return [[d, _median(v)] for d, v in sorted(by_day.items())]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _inventory(data: dict, runs: dict, final_att: dict, now: datetime, default_branch: str | None, tests: list[dict]) -> tuple[list, list]:
|
|
245
|
+
"""Tests added in the last two weeks and how they have done since; tests skipped now and since when."""
|
|
246
|
+
reports = sorted(({**r, "name": re.sub(r"\d{6,}", "#", r.get("name") or "")} for r in data.get("tests") or []
|
|
247
|
+
if (r["run"], r["att"]) in runs), key=lambda r: runs[(r["run"], r["att"])]["cr"])
|
|
248
|
+
finals = [r for r in reports if r["att"] == final_att[r["run"]]]
|
|
249
|
+
first = {}
|
|
250
|
+
for r in reports:
|
|
251
|
+
for tid in r.get("new") or []:
|
|
252
|
+
first.setdefault(tid, (runs[(r["run"], r["att"])]["cr"], r["name"]))
|
|
253
|
+
state = {t["id"]: t for t in tests}
|
|
254
|
+
new = []
|
|
255
|
+
for tid, (when, name) in first.items():
|
|
256
|
+
if _t(when) < now - timedelta(days=NEW_DAYS):
|
|
257
|
+
continue
|
|
258
|
+
since = [r for r in finals if r["name"] == name and runs[(r["run"], r["att"])]["cr"] >= when]
|
|
259
|
+
t = state.get(tid) or {}
|
|
260
|
+
new.append({"id": tid, "suite": name, "first": when, "runs": len(since),
|
|
261
|
+
"failures": sum(1 for r in since if any(f[0] == tid for f in r["failed"])),
|
|
262
|
+
"flake_events": t.get("flake_events", 0), "state": t.get("state", "ok")})
|
|
263
|
+
# Newest first, and the ones already failing or flaky above the rest.
|
|
264
|
+
new.sort(key=lambda x: x["first"], reverse=True)
|
|
265
|
+
new.sort(key=lambda x: x["state"] == "ok")
|
|
266
|
+
# Skips: only reports that carry a skip list prove anything, and only the default branch's.
|
|
267
|
+
listed = defaultdict(list)
|
|
268
|
+
for r in finals:
|
|
269
|
+
run = runs[(r["run"], r["att"])]
|
|
270
|
+
if "sk" in r and run.get("ev") in OWN_EVENTS and (default_branch is None or run.get("br") == default_branch):
|
|
271
|
+
listed[r["name"]].append(r)
|
|
272
|
+
found = {}
|
|
273
|
+
for name, rs in listed.items():
|
|
274
|
+
sets = [set(r["sk"]) for r in rs]
|
|
275
|
+
for tid in rs[-1]["sk"]:
|
|
276
|
+
k = len(rs) - 1
|
|
277
|
+
while k > 0 and tid in sets[k - 1]:
|
|
278
|
+
k -= 1
|
|
279
|
+
since = runs[(rs[k]["run"], rs[k]["att"])]["cr"]
|
|
280
|
+
fresh = k > 0 and _t(since) >= now - timedelta(days=NEW_SKIP_DAYS)
|
|
281
|
+
# One row per test across matrix jobs: skipped since the earliest, new only if new everywhere.
|
|
282
|
+
if tid in found:
|
|
283
|
+
t = found[tid]
|
|
284
|
+
t["suites"] += 1
|
|
285
|
+
t["new"] = t["new"] and fresh
|
|
286
|
+
t["since"] = min(t["since"], since)
|
|
287
|
+
else:
|
|
288
|
+
found[tid] = {"id": tid, "suite": name, "suites": 1, "since": since, "new": fresh}
|
|
289
|
+
skipped = [{**t, "days": (now - _t(t["since"])).days} for t in found.values()]
|
|
290
|
+
skipped.sort(key=lambda x: (not x["new"], -x["days"], x["id"]))
|
|
291
|
+
return new[:NEW_SHOWN], skipped[:SKIP_SHOWN]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
PRS_SHOWN = 50
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _coverage(data: dict, runs: dict, now: datetime, since: datetime, default_branch: str | None) -> dict | None:
|
|
298
|
+
"""Line coverage on the default branch (latest, daily trend, change over the window) and each pull request's latest
|
|
299
|
+
patch coverage; a run's widest report counts (matrix jobs measure the same code)."""
|
|
300
|
+
best = {}
|
|
301
|
+
for r in data.get("tests") or []:
|
|
302
|
+
run = runs.get((r["run"], r["att"]))
|
|
303
|
+
if run and r.get("cov") and r["cov"].get("lines") and (r["run"] not in best or r["cov"]["lines"] > best[r["run"]][1]["cov"]["lines"]):
|
|
304
|
+
best[r["run"]] = (run, r)
|
|
305
|
+
if not best:
|
|
306
|
+
return None
|
|
307
|
+
pct = lambda c: round(c["covered"] / c["lines"], 4) if c.get("lines") else None # noqa: E731
|
|
308
|
+
main = sorted(((run, r) for run, r in best.values() if run.get("ev") in OWN_EVENTS
|
|
309
|
+
and (default_branch is None or run.get("br") == default_branch)), key=lambda x: x[0]["cr"])
|
|
310
|
+
trend = _trend((_t(run["cr"]), pct(r["cov"])) for run, r in main)
|
|
311
|
+
in_window = [pct(r["cov"]) for run, r in main if _t(run["cr"]) >= since]
|
|
312
|
+
latest = main[-1] if main else None
|
|
313
|
+
prs = {}
|
|
314
|
+
for run, r in sorted(best.values(), key=lambda x: x[0]["cr"]):
|
|
315
|
+
p = r.get("patch")
|
|
316
|
+
if p and p.get("pr") and _t(run["cr"]) >= since:
|
|
317
|
+
prs[p["pr"]] = {"pr": p["pr"], "lines": p["lines"], "covered": p["covered"],
|
|
318
|
+
"pct": round(p["covered"] / p["lines"], 4) if p["lines"] else None, "missed": p.get("missed") or {},
|
|
319
|
+
"at": run["cr"]}
|
|
320
|
+
return {"latest": {"pct": pct(latest[1]["cov"]), "lines": latest[1]["cov"]["lines"], "covered": latest[1]["cov"]["covered"],
|
|
321
|
+
"format": latest[1]["cov"].get("format"), "at": latest[0]["cr"], "run": latest[0]["id"]} if latest else None,
|
|
322
|
+
"trend": trend, "change": round(in_window[-1] - in_window[0], 4) if len(in_window) >= 2 else None,
|
|
323
|
+
"prs": sorted(prs.values(), key=lambda x: x["at"], reverse=True)[:PRS_SHOWN]}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _groups(data: dict, runs: dict, final_att: dict, since: datetime, tests: list[dict]) -> list[dict]:
|
|
327
|
+
"""Failures with one cause (same root exception at the same place), across tests and runs, in the window; each
|
|
328
|
+
test also names the group it most often fails with. Records saved before causes existed are left out."""
|
|
329
|
+
seen = defaultdict(lambda: {"failures": 0, "tests": Counter(), "runs": set(), "first": None, "last": None,
|
|
330
|
+
"messages": Counter(), "examples": []})
|
|
331
|
+
for r in sorted(data.get("tests") or [], key=lambda r: runs.get((r["run"], r["att"]), {}).get("cr") or ""):
|
|
332
|
+
run = runs.get((r["run"], r["att"]))
|
|
333
|
+
if not run or r["att"] != final_att.get(r["run"]) or _t(run["cr"]) < since:
|
|
334
|
+
continue
|
|
335
|
+
for f in r["failed"]:
|
|
336
|
+
if len(f) < 6 or not f[3]:
|
|
337
|
+
continue
|
|
338
|
+
g = seen[f[3]]
|
|
339
|
+
g["failures"] += 1
|
|
340
|
+
g["tests"][f[0]] += 1
|
|
341
|
+
g["runs"].add(r["run"])
|
|
342
|
+
g["first"] = g["first"] or run["cr"]
|
|
343
|
+
g["last"] = run["cr"]
|
|
344
|
+
g["messages"][f[2]] += 1
|
|
345
|
+
g.update(where=f[4], type=f[5])
|
|
346
|
+
g["examples"] = ([{"run": r["run"], "att": r["att"], "test": f[0]}] + g["examples"])[:5]
|
|
347
|
+
out = [{"id": cid, "type": g["type"], "where": g["where"], "message": g["messages"].most_common(1)[0][0],
|
|
348
|
+
"failures": g["failures"], "tests": len(g["tests"]), "runs": len(g["runs"]), "first": g["first"],
|
|
349
|
+
"last": g["last"], "top_tests": [t for t, _ in g["tests"].most_common(5)], "examples": g["examples"]}
|
|
350
|
+
for cid, g in seen.items()]
|
|
351
|
+
out.sort(key=lambda g: (-g["tests"], -g["failures"], g["id"]))
|
|
352
|
+
usual = {}
|
|
353
|
+
for cid, g in seen.items():
|
|
354
|
+
for tid, n in g["tests"].items():
|
|
355
|
+
if n > usual.get(tid, (0, None))[0]:
|
|
356
|
+
usual[tid] = (n, cid)
|
|
357
|
+
for t in tests:
|
|
358
|
+
if t["id"] in usual:
|
|
359
|
+
t["group"] = usual[t["id"]][1]
|
|
360
|
+
return out[:MAX_GROUPS]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _worst(history: dict, items: list[dict], key) -> dict:
|
|
364
|
+
# Flaky first, then keeps failing, then one-off flakes; the most flakes first within each.
|
|
365
|
+
order = {"flaky": 0, "broken": 1, "suspect": 2}
|
|
366
|
+
ranked = sorted((i for i in items if key(i) in history), key=lambda i: (order.get(i["state"], 3), -i["flake_events"]))
|
|
367
|
+
return {key(i): history[key(i)] for i in ranked[:MAX_HISTORY]}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _job_reads_pr(name: str, jobs) -> bool:
|
|
371
|
+
# A matrix job is shown as "name (a, b)".
|
|
372
|
+
return any(name == j or name.startswith(j + " (") for j in jobs)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _row(run: dict, flaked: dict, passed: bool) -> list:
|
|
376
|
+
# One run on a history page: when, which attempt to open (the flaky one if any), passed, flake kind, branch, event.
|
|
377
|
+
ev = flaked.get(run["id"])
|
|
378
|
+
return [run["cr"], run["id"], ev["att"] if ev else run["att"], passed, ev["kind"] if ev else None, run.get("br"), run.get("ev")]
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _downstream(job: dict, attempt_jobs: list[dict]) -> bool:
|
|
382
|
+
# A gatekeeper job (alls-green, "Status") fails because another job already failed or was cancelled.
|
|
383
|
+
start = _t(job.get("st"))
|
|
384
|
+
return bool(start) and any(o is not job and (o["c"] in FAIL or o["c"] == "cancelled") and _t(o.get("en"))
|
|
385
|
+
and _t(o["en"]) <= start for o in attempt_jobs)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def analyze(data: dict, now: datetime, window_days: int = 30, default_branch: str | None = None,
|
|
389
|
+
pr_metadata: dict | None = None, config: dict | None = None, previous: dict | None = None,
|
|
390
|
+
pr_edits: set | None = None, attempts: bool = False) -> dict:
|
|
391
|
+
"""pr_metadata: {workflow path: job names} for jobs that read the PR's title, description or labels in workflows that
|
|
392
|
+
also run on PR edits or labels; the same code may pass and fail there, so those flips are not flakes. pr_edits: the
|
|
393
|
+
workflows that run on PR edits, published so a failure verdict can say "fix the PR"."""
|
|
394
|
+
pr_metadata = pr_metadata or {}
|
|
395
|
+
since = now - timedelta(days=window_days)
|
|
396
|
+
runs = {(r["id"], r["att"]): r for r in data["runs"]}
|
|
397
|
+
final_att = {}
|
|
398
|
+
for rid, att in runs:
|
|
399
|
+
final_att[rid] = max(att, final_att.get(rid, 0))
|
|
400
|
+
by_attempt = defaultdict(list)
|
|
401
|
+
for j in data["jobs"]:
|
|
402
|
+
by_attempt[(j["run"], j["att"])].append(j)
|
|
403
|
+
|
|
404
|
+
def key(rid, name):
|
|
405
|
+
return (runs[(rid, final_att[rid])].get("path"), name)
|
|
406
|
+
|
|
407
|
+
events = defaultdict(list) # (path, job) -> [(time, kind, failing step, minutes lost, pr numbers)]
|
|
408
|
+
infra = set()
|
|
409
|
+
# Signal 1: an attempt failed and a later attempt of the same run passed.
|
|
410
|
+
for rid, last in final_att.items():
|
|
411
|
+
outcome = {}
|
|
412
|
+
outage = set()
|
|
413
|
+
for att in range(1, last + 1):
|
|
414
|
+
jobs = [j for j in by_attempt.get((rid, att), []) if j["c"] in FAIL | PASS]
|
|
415
|
+
if len(jobs) >= OUTAGE_MIN_JOBS and sum(j["c"] in FAIL for j in jobs) / len(jobs) >= OUTAGE_SHARE:
|
|
416
|
+
outage.add(att)
|
|
417
|
+
for j in jobs:
|
|
418
|
+
outcome.setdefault(j["n"], []).append((att, j))
|
|
419
|
+
for name, seq in outcome.items():
|
|
420
|
+
fails = [(a, j) for a, j in seq if j["c"] in FAIL]
|
|
421
|
+
passes = [a for a, j in seq if j["c"] in PASS]
|
|
422
|
+
if not fails or not passes or min(fails, key=lambda x: x[0])[0] > max(passes):
|
|
423
|
+
continue
|
|
424
|
+
att, failed = min(fails, key=lambda x: x[0])
|
|
425
|
+
if att in outage:
|
|
426
|
+
infra.add(rid)
|
|
427
|
+
continue
|
|
428
|
+
if _downstream(failed, by_attempt.get((rid, att), [])):
|
|
429
|
+
continue
|
|
430
|
+
run = runs.get((rid, att)) or runs[(rid, final_att[rid])]
|
|
431
|
+
lost = (_secs(failed["st"], failed["en"]) or 0) / 60
|
|
432
|
+
events[key(rid, name)].append((_t(failed["en"]) or _t(run["cr"]), "retry", _failed_step(failed), lost,
|
|
433
|
+
run.get("pr") or [], {"run": rid, "att": att, "job": failed["id"], "kind": "retry"}))
|
|
434
|
+
# Signal 2: final results differ on the same code (tree) for the same event type.
|
|
435
|
+
finals = defaultdict(lambda: defaultdict(list))
|
|
436
|
+
for rid, last in final_att.items():
|
|
437
|
+
run = runs[(rid, last)]
|
|
438
|
+
for j in by_attempt.get((rid, last), []):
|
|
439
|
+
if j["c"] in FAIL | PASS:
|
|
440
|
+
finals[key(rid, j["n"])][(run.get("tree"), run.get("ev"), run.get("br"))].append((run, j))
|
|
441
|
+
for k, groups in finals.items():
|
|
442
|
+
for (tree, ev, _ref), items in groups.items():
|
|
443
|
+
# Scheduled and triggered runs on the same code can differ for real reasons (inputs, upstream runs).
|
|
444
|
+
reads_pr = ev in PR_EVENTS and _job_reads_pr(k[1], pr_metadata.get(k[0], ()))
|
|
445
|
+
if not tree or ev not in SAME_CODE_EVENTS or reads_pr:
|
|
446
|
+
continue
|
|
447
|
+
failed = [(r, j) for r, j in items if j["c"] in FAIL and r["id"] not in infra
|
|
448
|
+
and not _downstream(j, by_attempt.get((r["id"], r["att"]), []))]
|
|
449
|
+
if failed and any(j["c"] in PASS for _, j in items):
|
|
450
|
+
r, j = max(failed, key=lambda x: x[0]["cr"])
|
|
451
|
+
events[k].append((_t(j["en"]) or _t(r["cr"]), "flip", _failed_step(j), 0.0, r.get("pr") or [],
|
|
452
|
+
{"run": r["id"], "att": r["att"], "job": j["id"], "kind": "flip"}))
|
|
453
|
+
|
|
454
|
+
out_jobs, queue, minutes, job_history = [], [], Counter(), {}
|
|
455
|
+
pushes = _pushes(runs, final_att, default_branch)
|
|
456
|
+
for k in sorted(finals):
|
|
457
|
+
path, name = k
|
|
458
|
+
items = sorted(((r, j) for g in finals[k].values() for r, j in g), key=lambda x: (x[0]["cr"], x[0]["id"]))
|
|
459
|
+
in_window = [(r, j) for r, j in items if _t(r["cr"]) >= since]
|
|
460
|
+
ev_all = sorted(events.get(k, []), key=lambda e: e[0])
|
|
461
|
+
ev_win = [e for e in ev_all if e[0] >= since]
|
|
462
|
+
last_event = ev_all[-1][0] if ev_all else None
|
|
463
|
+
runs_since = sum(1 for r, _ in items if last_event and _t(r["cr"]) > last_event)
|
|
464
|
+
default = [j for r, j in items if default_branch is None or r.get("br") == default_branch]
|
|
465
|
+
broken = len(default) >= BROKEN_RUNS and all(j["c"] in FAIL for j in default[-BROKEN_RUNS:])
|
|
466
|
+
state = _state(ev_all, ev_win, runs_since, broken, now)
|
|
467
|
+
durations = [(r, s) for r, j in in_window if j["c"] in PASS and (s := _secs(j["st"], j["en"])) is not None]
|
|
468
|
+
recent = [s for r, s in durations if _t(r["cr"]) >= now - timedelta(days=7)]
|
|
469
|
+
before = [s for r, s in durations if _t(r["cr"]) < now - timedelta(days=7)]
|
|
470
|
+
slower = None
|
|
471
|
+
if len(recent) >= 5 and len(before) >= 5:
|
|
472
|
+
a, b = _median(before), _median(recent)
|
|
473
|
+
if b >= a * 1.25 and b - a >= 60:
|
|
474
|
+
slower = {"before_s": a, "after_s": b, "pct": round((b / a - 1) * 100)}
|
|
475
|
+
lo, hi = wilson(len(ev_win), len(in_window))
|
|
476
|
+
steps = Counter(e[2] for e in ev_win)
|
|
477
|
+
out_jobs.append({
|
|
478
|
+
"workflow": path, "job": name, "runs": len(in_window),
|
|
479
|
+
"failures": sum(1 for _, j in in_window if j["c"] in FAIL),
|
|
480
|
+
"flake_events": len(ev_win), "flake_rate": round(len(ev_win) / len(in_window), 4) if in_window else 0.0,
|
|
481
|
+
"rate_low": round(lo, 4), "rate_high": round(hi, 4), "state": state,
|
|
482
|
+
"last_flake": last_event.strftime("%Y-%m-%dT%H:%M:%SZ") if last_event else None,
|
|
483
|
+
"top_step": steps.most_common(1)[0][0] if steps else None, "flake_steps": sorted(steps),
|
|
484
|
+
"retry_minutes": round(sum(e[3] for e in ev_win), 1),
|
|
485
|
+
"prs_affected": sorted({p for e in ev_win for p in e[4]}), "evidence": [e[5] for e in ev_all[-5:]],
|
|
486
|
+
"median_s": _median([s for _, s in durations]), "p95_s": _pct([s for _, s in durations], 0.95),
|
|
487
|
+
"slower": slower, "trend": _trend((_t(r["cr"]), s) for r, s in durations),
|
|
488
|
+
# Each flake is placed at its run's start, so the run that flaked first counts after it, not before.
|
|
489
|
+
"began": _began([_t(r["cr"]) for r, _j in items],
|
|
490
|
+
[_t(runs[key]["cr"]) for e in ev_all if (key := (e[5]["run"], e[5]["att"])) in runs], pushes)})
|
|
491
|
+
if state in SHOWN:
|
|
492
|
+
flaked = {e[5]["run"]: e[5] for e in ev_all}
|
|
493
|
+
job_history[f"{path}|{name}"] = [_row(r, flaked, j["c"] in PASS) for r, j in items]
|
|
494
|
+
window_runs = {rid for (rid, att), r in runs.items() if _t(r["cr"]) >= since}
|
|
495
|
+
for (rid, att), jobs in by_attempt.items():
|
|
496
|
+
run = runs.get((rid, att)) or runs.get((rid, final_att.get(rid, att)))
|
|
497
|
+
if not run or rid not in window_runs:
|
|
498
|
+
continue
|
|
499
|
+
for j in jobs:
|
|
500
|
+
q = _secs(j.get("cr"), j.get("st"))
|
|
501
|
+
if q is not None:
|
|
502
|
+
queue.append(q)
|
|
503
|
+
d = _secs(j.get("st"), j.get("en"))
|
|
504
|
+
if d:
|
|
505
|
+
minutes[_os(j.get("lb") or [])] += math.ceil(d / 60)
|
|
506
|
+
reruns = sum(1 for rid in window_runs if final_att[rid] > 1)
|
|
507
|
+
tests, slow_tests, test_outages, test_history = _tests(data, runs, final_att, since, now, default_branch, config, previous)
|
|
508
|
+
failure_groups = _groups(data, runs, final_att, since, tests)
|
|
509
|
+
new_tests, skipped_tests = _inventory(data, runs, final_att, now, default_branch, tests)
|
|
510
|
+
if not attempts:
|
|
511
|
+
# Each flaky test's attempts feed only the Action's cost step, never a page.
|
|
512
|
+
for t in tests:
|
|
513
|
+
t.pop("_attempts", None)
|
|
514
|
+
return {
|
|
515
|
+
"failure_groups": failure_groups,
|
|
516
|
+
"weekly": _weekly(data, finals, events, now),
|
|
517
|
+
"changes": changes.annotate(_changes(data, finals, events, now)),
|
|
518
|
+
"history": {"jobs": _worst(job_history, out_jobs, lambda j: f"{j['workflow']}|{j['job']}"), "tests": test_history},
|
|
519
|
+
"tests": tests, "slow_tests": slow_tests, "test_outages": test_outages,
|
|
520
|
+
"new_tests": new_tests, "skipped_tests": skipped_tests,
|
|
521
|
+
"coverage": _coverage(data, runs, now, since, default_branch),
|
|
522
|
+
"has_test_reports": bool(data.get("tests")),
|
|
523
|
+
"window_days": window_days, "generated": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "default_branch": default_branch,
|
|
524
|
+
"runs": len(window_runs), "rerun_share": round(reruns / len(window_runs), 4) if window_runs else 0.0,
|
|
525
|
+
"infra_events": sum(1 for rid in infra if rid in window_runs),
|
|
526
|
+
"queue": {"median_s": _median(queue), "p95_s": _pct(queue, 0.95)},
|
|
527
|
+
"minutes": {k: minutes.get(k, 0) for k in ("linux", "windows", "macos", "other")},
|
|
528
|
+
"pushes": push_stats(data, now, window_days), "jobs": out_jobs,
|
|
529
|
+
# Workflows run by PR edits, so a failure there about the PR itself reads "fix the PR" (feedback.classify_run).
|
|
530
|
+
"pr_edit_workflows": sorted(set(pr_edits or ()) | {p for p, jobs in pr_metadata.items() if jobs})}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _changes(data: dict, finals: dict, events: dict, now: datetime) -> list[dict]:
|
|
534
|
+
"""This week's first-try green, push wait and flake share against the four weeks before, each judged against how
|
|
535
|
+
much this repo's weeks usually swing."""
|
|
536
|
+
return changes.calibrate(lambda t: _week_checks(data, finals, events, t), now)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _week_checks(data: dict, finals: dict, events: dict, now: datetime) -> list[dict]:
|
|
540
|
+
(ws, we), (bs, be) = changes.windows(now)
|
|
541
|
+
week, base = push_window(data, ws, we) or {}, push_window(data, bs, be) or {}
|
|
542
|
+
green = lambda p: round((p.get("first_try_share") or 0) * (p.get("pushes") or 0)) # noqa: E731
|
|
543
|
+
# Run and flake times come as text or as datetimes; both compare as UTC text.
|
|
544
|
+
at = lambda v: v.strftime("%Y-%m-%dT%H:%M:%SZ") if isinstance(v, datetime) else v or "" # noqa: E731
|
|
545
|
+
runs_in = lambda a, b: sum(1 for groups in finals.values() for items in groups.values() # noqa: E731
|
|
546
|
+
for r, _j in items if a <= at(r["cr"]) < b)
|
|
547
|
+
flakes_in = lambda a, b: sum(1 for evs in events.values() for e in evs if a <= at(e[0]) < b) # noqa: E731
|
|
548
|
+
jobs_now, jobs_base = runs_in(ws, we), runs_in(bs, be)
|
|
549
|
+
return [changes.share("ci.first_try_green", "pushes green on the first try", green(week), week.get("pushes") or 0,
|
|
550
|
+
green(base), base.get("pushes") or 0, better=1),
|
|
551
|
+
changes.times("ci.push_wait", "push to all checks done", [d / 60 for d in week.get("durations") or []],
|
|
552
|
+
[d / 60 for d in base.get("durations") or []], better=-1, unit="min"),
|
|
553
|
+
changes.share("ci.flake_share", "job runs that flaked", min(flakes_in(ws, we), jobs_now), jobs_now,
|
|
554
|
+
min(flakes_in(bs, be), jobs_base), jobs_base, better=-1)]
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _weekly(data: dict, finals: dict, events: dict, now: datetime) -> list[dict]:
|
|
558
|
+
"""Each week of the kept history: job runs, how many flaked, and the push wait measured like the benchmark."""
|
|
559
|
+
job_runs, flakes = Counter(), Counter()
|
|
560
|
+
for groups in finals.values():
|
|
561
|
+
for items in groups.values():
|
|
562
|
+
for r, _j in items:
|
|
563
|
+
job_runs[week_of(r["cr"])] += 1
|
|
564
|
+
for evs in events.values():
|
|
565
|
+
for e in evs:
|
|
566
|
+
flakes[week_of(e[0])] += 1
|
|
567
|
+
pushes = push_stats_by(data, week_of)
|
|
568
|
+
if not job_runs and not pushes:
|
|
569
|
+
return []
|
|
570
|
+
out, day, last = [], _t(min([*job_runs, *pushes]) + "T00:00:00Z"), week_of(now)
|
|
571
|
+
while (week := day.strftime("%Y-%m-%d")) <= last:
|
|
572
|
+
p = pushes.get(week) or {}
|
|
573
|
+
out.append({"week": week, "job_runs": job_runs[week], "flakes": flakes[week],
|
|
574
|
+
"flake_share": round(min(100.0, 100 * flakes[week] / job_runs[week]), 2) if job_runs[week] else None,
|
|
575
|
+
"push_median_s": p.get("median_s"), "first_try_share": p.get("first_try_share")})
|
|
576
|
+
day += timedelta(days=7)
|
|
577
|
+
return out
|