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/action.py
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from stackvitals.ci.analyze import SHOWN, analyze
|
|
12
|
+
from stackvitals.ci.api import ActionsAPI, APIError, OutOfBudget
|
|
13
|
+
from stackvitals.ci import bench
|
|
14
|
+
from stackvitals.ci.automation import (alert_message, apply_decisions, new_flakes, owners_of, quarantine_list, rerun_failed_jobs, send_alert,
|
|
15
|
+
should_rerun, sync_issues)
|
|
16
|
+
from stackvitals.ci.collect import PR_EVENTS, collect
|
|
17
|
+
from stackvitals.ci.feedback import (MARKER, classify, find_pr, our_comment, quarantine_note, quarantined_failures,
|
|
18
|
+
remembered, render_comment, upsert_comment, verdict_payload, blamed_sha)
|
|
19
|
+
from stackvitals.ci.owners import attach_owners, read_codeowners, test_files
|
|
20
|
+
from stackvitals.ci.report import detail_name, render_detail, render_html, render_markdown
|
|
21
|
+
from stackvitals.ci.store import DataStore
|
|
22
|
+
from stackvitals.ci.triage import MAX_CALLS, UNDECIDED, triage
|
|
23
|
+
from stackvitals.ci.triggers import pr_edit_workflows, pr_metadata_workflows
|
|
24
|
+
from stackvitals.ci.monitors import transitions
|
|
25
|
+
from stackvitals.core.store import published
|
|
26
|
+
from stackvitals.core.summary import write_ci_summary
|
|
27
|
+
from stackvitals import shell
|
|
28
|
+
|
|
29
|
+
BRANCH = "stackvitals-data"
|
|
30
|
+
LABEL = "CI Health"
|
|
31
|
+
GREEN = {"success", "neutral", "skipped"}
|
|
32
|
+
FEEDBACK_OVERLAP = timedelta(minutes=15)
|
|
33
|
+
MAX_PRS, MAX_RERUNS, MAX_LOOKUPS, AFTER_COLLECT = 20, 5, 30, 60
|
|
34
|
+
BOT = ("github-actions[bot]", "41898282+github-actions[bot]@users.noreply.github.com")
|
|
35
|
+
DATA_README = """# StackVitals data
|
|
36
|
+
|
|
37
|
+
This branch holds CI history collected by StackVitals: `ci/days/` (one file per day), `ci/state.json`,
|
|
38
|
+
and the dashboard in `docs/`. To publish the dashboard (public repos), set GitHub Pages to deploy from
|
|
39
|
+
this branch, folder `/docs`. Nothing here is sent anywhere else.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GitError(RuntimeError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DataBranch:
|
|
48
|
+
# A shallow clone of the data branch; the remote URL may carry a token, so it is never printed.
|
|
49
|
+
def __init__(self, remote: str, branch: str, path: Path):
|
|
50
|
+
self.remote, self.branch, self.path = remote, branch, Path(path)
|
|
51
|
+
|
|
52
|
+
def _git(self, *args, cwd=None, check=True) -> subprocess.CompletedProcess:
|
|
53
|
+
# Git for Windows can turn on a file watcher for every repo; a short-lived clone must not leave one running.
|
|
54
|
+
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_CONFIG_COUNT": "1",
|
|
55
|
+
"GIT_CONFIG_KEY_0": "core.fsmonitor", "GIT_CONFIG_VALUE_0": "false"}
|
|
56
|
+
run = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, env=env)
|
|
57
|
+
if check and run.returncode:
|
|
58
|
+
raise GitError(f"git {args[0]} failed: {run.stderr.replace(self.remote, '<remote>')[-300:]}")
|
|
59
|
+
return run
|
|
60
|
+
|
|
61
|
+
def _network(self, *args, cwd=None) -> subprocess.CompletedProcess:
|
|
62
|
+
# Network steps can hiccup ("could not read from remote"); try three times before giving up.
|
|
63
|
+
for attempt in range(3):
|
|
64
|
+
run = self._git(*args, cwd=cwd, check=False)
|
|
65
|
+
if run.returncode == 0:
|
|
66
|
+
return run
|
|
67
|
+
if self.path.exists() and args[0] == "clone":
|
|
68
|
+
shutil.rmtree(self.path, ignore_errors=True)
|
|
69
|
+
time.sleep(2 * (attempt + 1))
|
|
70
|
+
raise GitError(f"git {args[0]} failed: {run.stderr.replace(self.remote, '<remote>')[-300:]}")
|
|
71
|
+
|
|
72
|
+
def open(self) -> None:
|
|
73
|
+
if self.path.exists():
|
|
74
|
+
shutil.rmtree(self.path)
|
|
75
|
+
heads = self._network("ls-remote", "--heads", self.remote, self.branch)
|
|
76
|
+
if heads.stdout.strip():
|
|
77
|
+
self._network("clone", "-q", "--depth", "1", "--branch", self.branch, "--single-branch", self.remote,
|
|
78
|
+
str(self.path))
|
|
79
|
+
return
|
|
80
|
+
# First run: an empty branch with no shared history with the code.
|
|
81
|
+
self.path.mkdir(parents=True)
|
|
82
|
+
self._git("init", "-q", cwd=self.path)
|
|
83
|
+
self._git("checkout", "-q", "--orphan", self.branch, cwd=self.path)
|
|
84
|
+
self._git("remote", "add", "origin", self.remote, cwd=self.path)
|
|
85
|
+
|
|
86
|
+
def dirty(self) -> bool:
|
|
87
|
+
self._git("add", "-A", cwd=self.path)
|
|
88
|
+
return bool(self._git("diff", "--cached", "--quiet", cwd=self.path, check=False).returncode)
|
|
89
|
+
|
|
90
|
+
def squash(self, message: str) -> str:
|
|
91
|
+
"""Rewrite the branch to one commit holding the current files; refused if anyone pushed in between."""
|
|
92
|
+
expected = self._git("rev-parse", "HEAD", cwd=self.path).stdout.strip()
|
|
93
|
+
self._git("add", "-A", cwd=self.path)
|
|
94
|
+
self._git("checkout", "-q", "--orphan", "stackvitals-squash", cwd=self.path)
|
|
95
|
+
self._git("-c", f"user.name={BOT[0]}", "-c", f"user.email={BOT[1]}", "commit", "-q", "-m", message, cwd=self.path)
|
|
96
|
+
push = self._git("push", "-q", f"--force-with-lease=refs/heads/{self.branch}:{expected}", "origin",
|
|
97
|
+
f"HEAD:refs/heads/{self.branch}", cwd=self.path, check=False)
|
|
98
|
+
if push.returncode == 0:
|
|
99
|
+
return "pushed"
|
|
100
|
+
if "rejected" in push.stderr or "stale info" in push.stderr:
|
|
101
|
+
return "rejected"
|
|
102
|
+
raise GitError(f"git push failed: {push.stderr.replace(self.remote, '<remote>')[-300:]}")
|
|
103
|
+
|
|
104
|
+
def commit_push(self, message: str) -> str:
|
|
105
|
+
if not self.dirty():
|
|
106
|
+
return "unchanged"
|
|
107
|
+
self._git("-c", f"user.name={BOT[0]}", "-c", f"user.email={BOT[1]}", "commit", "-q", "-m", message, cwd=self.path)
|
|
108
|
+
for attempt in range(3):
|
|
109
|
+
push = self._git("push", "-q", "origin", f"HEAD:refs/heads/{self.branch}", cwd=self.path, check=False)
|
|
110
|
+
if push.returncode == 0:
|
|
111
|
+
return "pushed"
|
|
112
|
+
# The server said no (protected branch, push rule, hook): trying again can't help.
|
|
113
|
+
if "[remote rejected]" in push.stderr and "cannot lock ref" not in push.stderr:
|
|
114
|
+
raise GitError(f"the server refused the push to {self.branch} (a protected branch, push rule or hook, or "
|
|
115
|
+
f"a token that can't write): {push.stderr.replace(self.remote, '<remote>')[-300:]}")
|
|
116
|
+
# Someone else pushed first: the caller starts again on top of their data.
|
|
117
|
+
if "rejected" in push.stderr or "fetch first" in push.stderr or "non-fast-forward" in push.stderr:
|
|
118
|
+
return "rejected"
|
|
119
|
+
time.sleep(2 * (attempt + 1))
|
|
120
|
+
raise GitError(f"git push failed: {push.stderr.replace(self.remote, '<remote>')[-300:]}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _own_workflow(env: dict) -> str | None:
|
|
124
|
+
# GITHUB_WORKFLOW_REF looks like owner/repo/.github/workflows/name.yml@refs/heads/main.
|
|
125
|
+
ref = env.get("GITHUB_WORKFLOW_REF") or ""
|
|
126
|
+
repo = env.get("GITHUB_REPOSITORY") or ""
|
|
127
|
+
return ref.split("@")[0][len(repo) + 1:] if ref.startswith(repo + "/") else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def write_outputs(root: Path, result: dict, repo: str, site: str = "docs", data_readme: bool = True) -> None:
|
|
131
|
+
docs = root / site
|
|
132
|
+
docs.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
# The header links only the module pages already written, so a link never leads nowhere.
|
|
134
|
+
pages = shell.present(docs) | {"ci.html"}
|
|
135
|
+
write_details(docs, result, repo, pages)
|
|
136
|
+
# index.html is the app now (core/webapp.py); this is the printable CI page.
|
|
137
|
+
(docs / "ci.html").write_text(render_html(result, repo, pages), encoding="utf-8")
|
|
138
|
+
# The per-run history goes only into the pages; the public data file stays small.
|
|
139
|
+
out = {k: v for k, v in result.items() if k != "history"}
|
|
140
|
+
from stackvitals.ci.investigate import prompt
|
|
141
|
+
# A prompt to hand an AI agent for the biggest failure groups; the app copies it.
|
|
142
|
+
out["failure_groups"] = [{**g, "prompt": prompt(repo, g, result.get("tests") or [])} if k < PROMPTS else g
|
|
143
|
+
for k, g in enumerate(result.get("failure_groups") or [])]
|
|
144
|
+
(docs / "data.json").write_text(json.dumps(published(out), indent=1), encoding="utf-8")
|
|
145
|
+
(docs / ".nojekyll").write_text("", encoding="utf-8")
|
|
146
|
+
(docs / "badge.json").write_text(json.dumps(bench.badge(result.get("pushes"))), encoding="utf-8")
|
|
147
|
+
if data_readme:
|
|
148
|
+
(root / "README.md").write_text(DATA_README, encoding="utf-8")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
PROMPTS = 10
|
|
152
|
+
DETAIL_FILE = re.compile(r"(job|test)-[a-z0-9-]*-[0-9a-f]{8}\.html")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def write_details(docs: Path, result: dict, repo: str, pages) -> None:
|
|
156
|
+
"""A history page per test or job that is not ok; pages of items that are ok again are removed (only ours)."""
|
|
157
|
+
folder = docs / "history"
|
|
158
|
+
if folder.is_dir():
|
|
159
|
+
for old in folder.iterdir():
|
|
160
|
+
if DETAIL_FILE.fullmatch(old.name):
|
|
161
|
+
old.unlink()
|
|
162
|
+
history = result.get("history") or {}
|
|
163
|
+
items = [("job", f"{j['workflow']}|{j['job']}", j) for j in result.get("jobs") or []]
|
|
164
|
+
items += [("test", t["id"], t) for t in result.get("tests") or []]
|
|
165
|
+
for kind, ident, item in items:
|
|
166
|
+
rows = (history.get(kind + "s") or {}).get(ident)
|
|
167
|
+
if rows:
|
|
168
|
+
folder.mkdir(exist_ok=True)
|
|
169
|
+
(folder / detail_name(kind, ident)).write_text(render_detail(kind, ident, item, rows, result, repo, pages),
|
|
170
|
+
encoding="utf-8")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def add_owners(api, store, result: dict, ref: str | None, today: str) -> None:
|
|
174
|
+
"""Owners for what the dashboard lists, from CODEOWNERS (a few requests a day); never stops a run."""
|
|
175
|
+
tests = [t["id"] for t in result.get("tests") or [] if t["state"] in SHOWN]
|
|
176
|
+
if not tests and not any(j["state"] in SHOWN for j in result.get("jobs") or []):
|
|
177
|
+
return
|
|
178
|
+
try:
|
|
179
|
+
co = read_codeowners(api, ref, store, today)
|
|
180
|
+
attach_owners(result, co, test_files(api, ref, store, today, tests) if co["text"] and tests else {})
|
|
181
|
+
except (APIError, OutOfBudget):
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _trigger(env: dict) -> dict | None:
|
|
186
|
+
# The finished CI run that started this one (workflow_run trigger), if any.
|
|
187
|
+
if env.get("GITHUB_EVENT_NAME") != "workflow_run" or not env.get("GITHUB_EVENT_PATH"):
|
|
188
|
+
return None
|
|
189
|
+
try:
|
|
190
|
+
return json.loads(Path(env["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8"))
|
|
191
|
+
except (OSError, ValueError):
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _latest(data: dict) -> dict:
|
|
196
|
+
latest = {}
|
|
197
|
+
for r in data["runs"]:
|
|
198
|
+
if r["att"] >= latest.get(r["id"], {}).get("att", 0):
|
|
199
|
+
latest[r["id"]] = r
|
|
200
|
+
return latest
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _finished_since(latest: dict, old: dict | None, trigger_id) -> list[dict]:
|
|
204
|
+
# GitHub cancels queued runs of a busy concurrency group, so each run also covers what finished since the last.
|
|
205
|
+
ids = {trigger_id} if trigger_id else set()
|
|
206
|
+
if old and old.get("generated"):
|
|
207
|
+
cut = datetime.fromisoformat(old["generated"].replace("Z", "+00:00")) - FEEDBACK_OVERLAP
|
|
208
|
+
ids |= {rid for rid, r in latest.items() if (r.get("up") or "") >= cut.strftime("%Y-%m-%dT%H:%M:%SZ")}
|
|
209
|
+
return [latest[i] for i in sorted(ids) if i in latest]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _same_pr(r: dict, seed: dict, pr: int) -> bool:
|
|
213
|
+
if r.get("ev") not in PR_EVENTS:
|
|
214
|
+
return False
|
|
215
|
+
if pr in (r.get("pr") or []):
|
|
216
|
+
return True
|
|
217
|
+
return not r.get("pr") and bool(r.get("ho")) and (r.get("ho"), r.get("br")) == (seed.get("ho"), seed.get("br"))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _pr_of(api, r: dict) -> int | None:
|
|
221
|
+
if r.get("pr"):
|
|
222
|
+
return r["pr"][0]
|
|
223
|
+
if not r.get("ho") or not r.get("br"):
|
|
224
|
+
return None
|
|
225
|
+
found = api._get(f"/repos/{api.repo}/pulls", {"state": "open", "head": f"{r['ho']}:{r['br']}"})
|
|
226
|
+
return found[0]["number"] if found else None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _patch_of(data: dict, run_ids: set) -> dict | None:
|
|
230
|
+
"""The widest patch coverage measured in these runs (matrix jobs measure the same code)."""
|
|
231
|
+
found = [t["patch"] for t in data.get("tests") or [] if t["run"] in run_ids and t.get("patch")]
|
|
232
|
+
return max(found, key=lambda p: p.get("lines") or 0) if found else None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _triage_on(settings: dict | None, private: bool = False, notes: list | None = None) -> bool:
|
|
236
|
+
t = ((settings or {}).get("ci") or {}).get("triage") or {}
|
|
237
|
+
if t.get("provider", "off") == "off" or not os.environ.get("STACKVITALS_TRIAGE_KEY"):
|
|
238
|
+
return False
|
|
239
|
+
if private and not t.get("allow_private"):
|
|
240
|
+
if notes is not None and not any("ci.triage" in n for n in notes):
|
|
241
|
+
notes.append("Model triage is off for this private repo: set [ci.triage] allow_private = true once your "
|
|
242
|
+
"provider's terms suit you (free tiers may keep what they're sent).")
|
|
243
|
+
return False
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _pr_files(api, repo: str, pr: int) -> list[str]:
|
|
248
|
+
try:
|
|
249
|
+
return [f["filename"] for f in api._get(f"/repos/{repo}/pulls/{pr}/files", {"per_page": 100}) or []]
|
|
250
|
+
except (APIError, OutOfBudget):
|
|
251
|
+
return []
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _pr_feedback(recent: list[dict], latest: dict, payload: dict | None, api, data: dict, result: dict,
|
|
255
|
+
repo: str, quarantined: set | None = None, comments: bool = True, verdict: bool = False,
|
|
256
|
+
notes: list | None = None, min_patch: float = 0.0, logs: dict | None = None,
|
|
257
|
+
settings: dict | None = None, private: bool = False, decided: dict | None = None) -> str | None:
|
|
258
|
+
# One comment per PR about every failing workflow on its newest code; a passing PR only edits an old comment.
|
|
259
|
+
trigger = (payload or {}).get("workflow_run") or {}
|
|
260
|
+
prs, looked = {}, {}
|
|
261
|
+
for r in recent:
|
|
262
|
+
if r.get("ev") not in PR_EVENTS or len(prs) >= MAX_PRS:
|
|
263
|
+
continue
|
|
264
|
+
if r["id"] == trigger.get("id"):
|
|
265
|
+
pr = find_pr(api, payload)
|
|
266
|
+
elif r.get("pr"):
|
|
267
|
+
pr = r["pr"][0]
|
|
268
|
+
elif (r.get("ho"), r.get("br")) in looked or len(looked) < MAX_LOOKUPS:
|
|
269
|
+
# Fork PR runs carry no PR number: one lookup per head owner and branch, and only a few per run.
|
|
270
|
+
key = (r.get("ho"), r.get("br"))
|
|
271
|
+
pr = looked[key] if key in looked else looked.setdefault(key, _pr_of(api, r))
|
|
272
|
+
else:
|
|
273
|
+
pr = None
|
|
274
|
+
if pr:
|
|
275
|
+
prs.setdefault(pr, r)
|
|
276
|
+
outcomes = []
|
|
277
|
+
|
|
278
|
+
def post_verdict(sha: str, code: list[dict], items: list[dict], held: bool = False) -> None:
|
|
279
|
+
try:
|
|
280
|
+
# Cancelled, timed out or never started blocks too; only green passes (known flakes are re-run, never let through).
|
|
281
|
+
stopped = [{"kind": "run", "name": r.get("wfn") or "CI", "verdict": r.get("c")} for r in code
|
|
282
|
+
if r.get("c") not in GREEN and r.get("c") != "failure"]
|
|
283
|
+
running = [w for w in (api._get(f"/repos/{repo}/actions/runs", {"head_sha": sha, "per_page": 50}) or {})
|
|
284
|
+
.get("workflow_runs") or [] if w.get("status") != "completed" and w.get("path") != own]
|
|
285
|
+
patch = _patch_of(data, {r["id"] for r in code})
|
|
286
|
+
api.send("POST", f"/repos/{repo}/check-runs",
|
|
287
|
+
{**verdict_payload(items + stopped, all(r.get("c") in GREEN for r in code), len(running), patch, min_patch,
|
|
288
|
+
held),
|
|
289
|
+
"head_sha": sha})
|
|
290
|
+
except (APIError, OutOfBudget) as exc:
|
|
291
|
+
if notes is not None:
|
|
292
|
+
notes.append(f"Could not post the StackVitals verdict check ({exc}); it needs `checks: write`.")
|
|
293
|
+
own = (os.environ.get("GITHUB_WORKFLOW_REF") or "").split("@")[0].split("/", 2)[-1] or None
|
|
294
|
+
strict = verdict and bool(((settings or {}).get("automation") or {}).get("verdict_strict"))
|
|
295
|
+
calls_left = MAX_CALLS
|
|
296
|
+
for pr, seed in prs.items():
|
|
297
|
+
mine = [r for r in latest.values() if _same_pr(r, seed, pr)] or [seed]
|
|
298
|
+
newest = max(mine, key=lambda r: r.get("cr") or "")["sha"]
|
|
299
|
+
code = [r for r in mine if r["sha"] == newest]
|
|
300
|
+
items = [i for r in code if r.get("c") == "failure"
|
|
301
|
+
for i in classify(api, data, result, r["id"], logs)]
|
|
302
|
+
found = None
|
|
303
|
+
if comments and items and _triage_on(settings, private, notes):
|
|
304
|
+
# Advice only: the verdict check below is posted from the same verdicts either way. Views already in our
|
|
305
|
+
# comment are reused, and one run makes at most MAX_CALLS calls across all its pull requests.
|
|
306
|
+
found = our_comment(api, pr)
|
|
307
|
+
read = {jid: sec for r in code for jid, sec in ((logs or {}).get(r["id"]) or {}).items()}
|
|
308
|
+
needs = any(i["verdict"] in UNDECIDED and str(i.get("job_id")) not in remembered((found or {}).get("body"))
|
|
309
|
+
for i in items)
|
|
310
|
+
calls_left -= triage(items, read, _pr_files(api, repo, pr) if needs and calls_left else [], settings,
|
|
311
|
+
os.environ.get("STACKVITALS_TRIAGE_KEY"), private=private,
|
|
312
|
+
memory=remembered((found or {}).get("body")), limit=calls_left)
|
|
313
|
+
held = False
|
|
314
|
+
if strict and not items and all(r.get("c") in GREEN for r in code):
|
|
315
|
+
# Strict mode: the comment blamed this very commit on the change, and CI went green only on a re-run.
|
|
316
|
+
found = found or our_comment(api, pr)
|
|
317
|
+
if blamed_sha((found or {}).get("body")) == newest:
|
|
318
|
+
ok = (decided or {}).get(f"rerun:{pr}") or {}
|
|
319
|
+
held = not (ok.get("verb") == "accept" and (ok.get("at") or "") >= ((found or {}).get("updated_at") or ""))
|
|
320
|
+
if verdict:
|
|
321
|
+
post_verdict(newest, code, items, held)
|
|
322
|
+
if not comments:
|
|
323
|
+
continue
|
|
324
|
+
if held:
|
|
325
|
+
note = (f"{MARKER}\n### StackVitals: a re-run turned this green\nA failure on this commit looked like the change's own "
|
|
326
|
+
"and passed when run again. A green re-run doesn't prove the change clean, so this repo's verdict waits: "
|
|
327
|
+
"after a look, comment `/stackvitals accept rerun`.\n<!-- stackvitals:blamed " + newest + " -->")
|
|
328
|
+
outcomes.append(upsert_comment(api, pr, note, found))
|
|
329
|
+
continue
|
|
330
|
+
if items:
|
|
331
|
+
outcomes.append(upsert_comment(api, pr, render_comment(items, repo, newest), found))
|
|
332
|
+
elif all(r.get("c") in GREEN for r in code):
|
|
333
|
+
let_through = quarantined_failures(data, [r["id"] for r in code], quarantined)
|
|
334
|
+
if let_through:
|
|
335
|
+
# Green only because quarantined tests were let through: say so, or a real failure could hide there.
|
|
336
|
+
note = quarantine_note(let_through, repo, result, datetime.now(timezone.utc))
|
|
337
|
+
outcomes.append(upsert_comment(api, pr, note))
|
|
338
|
+
continue
|
|
339
|
+
existing = [c for c in api._get(f"/repos/{repo}/issues/{pr}/comments", {"per_page": 100}) or []
|
|
340
|
+
if MARKER in (c.get("body") or "")]
|
|
341
|
+
if existing:
|
|
342
|
+
outcomes.append(upsert_comment(api, pr, f"{MARKER}\n### StackVitals: CI is passing now\nThe latest run passed."))
|
|
343
|
+
if verdict:
|
|
344
|
+
# A merge queue tests its own commit, so a required verdict must be posted there too.
|
|
345
|
+
queued = {r["sha"] for r in recent if r.get("ev") == "merge_group" and r.get("sha")}
|
|
346
|
+
for sha in sorted(queued)[:MAX_PRS]:
|
|
347
|
+
code = [r for r in latest.values() if r.get("sha") == sha and r.get("ev") == "merge_group"]
|
|
348
|
+
post_verdict(sha, code, [i for r in code if r.get("c") == "failure"
|
|
349
|
+
for i in classify(api, data, result, r["id"], logs)])
|
|
350
|
+
return "created" if "created" in outcomes else "updated" if outcomes else None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _previous(root: Path) -> dict | None:
|
|
354
|
+
# Last run's findings, to alert only on what newly turned flaky.
|
|
355
|
+
try:
|
|
356
|
+
return json.loads((root / "docs" / "data.json").read_text(encoding="utf-8"))
|
|
357
|
+
except (OSError, ValueError):
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def ci_step(ws, env: dict, now: datetime, days: int = 90, window_days: int = 30, retention_days: int = 180,
|
|
362
|
+
budget: int = 800, exclude: list[str] | None = None, make_api=ActionsAPI, tests: bool = True,
|
|
363
|
+
artifact_pattern: str | None = None, pr_comments: bool = True, rerun_flaky: bool = False,
|
|
364
|
+
quarantine: bool = False, issues: bool = False, webhook: str | None = None, mention_owners: bool = False,
|
|
365
|
+
assign_owners: bool = False, verdict_check: bool = False) -> dict:
|
|
366
|
+
"""CI Health's part of a job: collect into the shared checkout, analyze, write the pages; what needs the commit
|
|
367
|
+
first (re-runs, comments, issues, alerts, the summary) is registered as the job's after-commit hook."""
|
|
368
|
+
repo, token = ws.repo, env.get("STACKVITALS_TOKEN") or env.get("GITHUB_TOKEN")
|
|
369
|
+
skip = [p for p in [_own_workflow(env), *(exclude or [])] if p]
|
|
370
|
+
payload = _trigger(env)
|
|
371
|
+
store = DataStore(ws.path)
|
|
372
|
+
# Collection may spend the budget; the repo lookup comes first and a little is kept for feedback.
|
|
373
|
+
api = make_api(token, repo, max_requests=max(budget - AFTER_COLLECT, 1))
|
|
374
|
+
info = ws.cache.get("repo_info") or api._get(f"/repos/{repo}")
|
|
375
|
+
stats = collect(api, store, now, days=days, exclude=skip, tests=tests,
|
|
376
|
+
**({"artifact_pattern": artifact_pattern} if artifact_pattern else {}))
|
|
377
|
+
api.max_requests = budget
|
|
378
|
+
today = now.date().isoformat()
|
|
379
|
+
store.compact(today, retention_days)
|
|
380
|
+
data = store.load()
|
|
381
|
+
ws.cache["ci"] = data
|
|
382
|
+
meta = pr_metadata_workflows(api, store, data, info.get("default_branch"), today)
|
|
383
|
+
settings, setting_notes = ws.cache.get("settings"), []
|
|
384
|
+
if settings is None:
|
|
385
|
+
# Run on its own (no job-wide read): the same reader, its notes in CI's own summary.
|
|
386
|
+
from stackvitals.core.settings import load
|
|
387
|
+
got = load(api, ws.path, now)
|
|
388
|
+
settings, setting_notes = got.cfg, got.notes
|
|
389
|
+
ws.cache["settings"] = settings
|
|
390
|
+
status_file = ws.path / "ci" / "status.json"
|
|
391
|
+
try:
|
|
392
|
+
previous_status = json.loads(status_file.read_text(encoding="utf-8"))
|
|
393
|
+
except (OSError, ValueError):
|
|
394
|
+
previous_status = {}
|
|
395
|
+
result = analyze(data, now, window_days, default_branch=info.get("default_branch"), pr_metadata=meta, attempts=True,
|
|
396
|
+
pr_edits=pr_edit_workflows(store),
|
|
397
|
+
config=settings, previous=previous_status)
|
|
398
|
+
# Each test's status with the day it began, and one line per change, so "flaky since" survives every run.
|
|
399
|
+
status, changes = transitions(previous_status, result.get("tests") or [], now)
|
|
400
|
+
status_file.write_text(json.dumps(status, indent=1, sort_keys=True), encoding="utf-8")
|
|
401
|
+
if changes:
|
|
402
|
+
log = ws.path / "ci" / "transitions" / f"{today}.ndjson"
|
|
403
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
404
|
+
with log.open("a", encoding="utf-8", newline="\n") as f:
|
|
405
|
+
f.writelines(json.dumps(c, separators=(",", ":")) + "\n" for c in changes)
|
|
406
|
+
from stackvitals.ci.cost import report as cost_report
|
|
407
|
+
# What CI costs in minutes and money over the last 30 days, and what flaky re-runs add.
|
|
408
|
+
result["cost"] = cost_report(data, now, private=bool(info.get("private")), days=30)
|
|
409
|
+
from stackvitals.ci import impact
|
|
410
|
+
from stackvitals.flow.store import FlowStore
|
|
411
|
+
# Test impact: learned from past PRs' changed files and real failures, and replayed on them before anyone trusts it.
|
|
412
|
+
if (ws.path / "flow").is_dir():
|
|
413
|
+
pr_paths = {p["n"]: p.get("paths") or [] for p in FlowStore(ws.path).load().get("closed") or [] if p.get("paths")}
|
|
414
|
+
hist = impact.history_from(data, pr_paths, {t["id"] for t in result.get("tests") or [] if t.get("state") == "flaky"})
|
|
415
|
+
files = sorted({f for _c, failed in hist for f in failed} | {p for paths in pr_paths.values() for p in paths if impact.TEST.search(p)})
|
|
416
|
+
result["impact"] = impact.backtest(hist, files) if hist else None
|
|
417
|
+
(ws.path / "ci" / "impact.json").write_text(json.dumps({"learned": impact.learn(hist), "backtest": result["impact"]},
|
|
418
|
+
sort_keys=True), encoding="utf-8")
|
|
419
|
+
from stackvitals.ci.cost import flaky_tests
|
|
420
|
+
# Which flaky tests cost the most re-run minutes, and how many PRs each got in the way of.
|
|
421
|
+
result["cost"]["tests"] = flaky_tests(data, result.get("tests") or [], private=bool(info.get("private")))
|
|
422
|
+
for t in result.get("tests") or []:
|
|
423
|
+
t.pop("_attempts", None)
|
|
424
|
+
# Comparisons come from a public file only (none before launch): no extra request otherwise.
|
|
425
|
+
result["benchmark"] = bench.compare(result["pushes"], bench.load(None, ws.work / "bench"),
|
|
426
|
+
info.get("language"), info.get("stargazers_count"))
|
|
427
|
+
share_doc = None
|
|
428
|
+
if ((settings or {}).get("benchmarks") or {}).get("share"):
|
|
429
|
+
from stackvitals.ci import share
|
|
430
|
+
# The id file goes into this run's commit, so next month's figures replace this month's.
|
|
431
|
+
share_doc = share.figures(result["pushes"], info.get("language"), info.get("stargazers_count"),
|
|
432
|
+
share.share_id(ws.path), now, repo if settings["benchmarks"].get("name") else None)
|
|
433
|
+
shared = bench.compare(result["pushes"], bench.load(None, ws.work / "bench", name=bench.SHARED_FILE,
|
|
434
|
+
url_env="STACKVITALS_SHARED_BENCH_URL", default_url=bench.SHARED_URL),
|
|
435
|
+
info.get("language"), info.get("stargazers_count"), everyone="repos that shared their figures")
|
|
436
|
+
# Shared peers resemble a private team's work more than the crawl's sample, unless only they are a loose fit.
|
|
437
|
+
if shared and (not shared["fit"] or not result["benchmark"] or result["benchmark"]["fit"]):
|
|
438
|
+
result["benchmark"] = {**shared, "group": f"shared: {shared['group']}", "peers": bench.SHARED_ARE}
|
|
439
|
+
add_owners(api, store, result, info.get("default_branch"), today)
|
|
440
|
+
# What people decided on GitHub (quarantine, mute, acknowledge) shapes the list, the alerts, the issues and the page.
|
|
441
|
+
from stackvitals.core.decisions import Store as Decisions
|
|
442
|
+
decided = Decisions(ws.path).state(now.date())
|
|
443
|
+
apply_decisions(result, decided)
|
|
444
|
+
feedback = Decisions(ws.path).feedback()
|
|
445
|
+
if feedback:
|
|
446
|
+
from stackvitals.core.decisions import verdict_accuracy
|
|
447
|
+
# How often this team confirmed what StackVitals said, per verdict: the repo's own running accuracy (counts only).
|
|
448
|
+
result["verdict_feedback"] = verdict_accuracy(feedback)
|
|
449
|
+
old = _previous(ws.path)
|
|
450
|
+
latest = _latest(data)
|
|
451
|
+
recent = _finished_since(latest, old, ((payload or {}).get("workflow_run") or {}).get("id"))
|
|
452
|
+
# Failed jobs' logs, read once per run, show outside failures (a registry or the runner) the history can't.
|
|
453
|
+
logs = {}
|
|
454
|
+
to_rerun = []
|
|
455
|
+
if rerun_flaky:
|
|
456
|
+
# Decided and remembered before the commit, so overlapping runs never re-run a run twice.
|
|
457
|
+
done = store.state().get("reran", [])
|
|
458
|
+
for r in recent:
|
|
459
|
+
# Only first attempts can be re-run, so only their logs are read, and only until enough runs are picked.
|
|
460
|
+
if len(to_rerun) >= MAX_RERUNS or r.get("c") != "failure" or r["id"] in done or r["att"] != 1:
|
|
461
|
+
continue
|
|
462
|
+
if should_rerun(classify(api, data, result, r["id"], logs), r["att"]):
|
|
463
|
+
to_rerun.append(r["id"])
|
|
464
|
+
if to_rerun:
|
|
465
|
+
store.save_state({**store.state(), "reran": (done + to_rerun)[-200:]})
|
|
466
|
+
from stackvitals.ci.tickets import sync as sync_tickets
|
|
467
|
+
# A Jira or Linear ticket per flaky test, when the team asked for one; before the commit, so what was filed is kept.
|
|
468
|
+
tickets = sync_tickets(ws.path, result, settings or {}, env)
|
|
469
|
+
qfile = ws.path / "ci" / "quarantine.json"
|
|
470
|
+
if quarantine:
|
|
471
|
+
qfile.write_text(json.dumps(quarantine_list(result, settings, decided, now)), encoding="utf-8")
|
|
472
|
+
from stackvitals.ci.automation import expired
|
|
473
|
+
# Flaky tests past [ci.quarantine] max_days fail builds again; the page lists them as overdue for a fix.
|
|
474
|
+
result["quarantine_expired"] = expired(result, settings, now)
|
|
475
|
+
elif qfile.exists():
|
|
476
|
+
# Turning quarantine off must stop it at once, so the old list is removed.
|
|
477
|
+
qfile.unlink()
|
|
478
|
+
write_outputs(ws.path, result, repo)
|
|
479
|
+
write_ci_summary(ws.path, result, now)
|
|
480
|
+
fresh = new_flakes(old, result)
|
|
481
|
+
out = {"collect": stats, "flaky": sum(j["state"] == "flaky" for j in result["jobs"]), "comment": None, "rerun": False,
|
|
482
|
+
"new_flaky": fresh}
|
|
483
|
+
|
|
484
|
+
def after_commit():
|
|
485
|
+
notes = list(setting_notes)
|
|
486
|
+
# Re-runs go first: they are already recorded as done, so nothing after the commit may stop them.
|
|
487
|
+
for rid in to_rerun:
|
|
488
|
+
try:
|
|
489
|
+
rerun_failed_jobs(api, rid)
|
|
490
|
+
out["rerun"] = True
|
|
491
|
+
notes.append(f"Run {rid} failed only on known flakes or outside failures, so its failed jobs were re-run once.")
|
|
492
|
+
except (APIError, OutOfBudget) as exc:
|
|
493
|
+
notes.append(f"Could not re-run run {rid} ({exc}); add `actions: write` to the workflow permissions.")
|
|
494
|
+
if verdict_check and info.get("private") and (env.get("STACKVITALS_EVERY_CI_RUN") or "").lower() != "true":
|
|
495
|
+
# The workflow starts no job for a passing CI run on a private repo, so the check would wait for the next
|
|
496
|
+
# 3-hourly run to turn green.
|
|
497
|
+
notes.append("The verdict check is on, but CI runs that pass don't start StackVitals on this private repo: set the "
|
|
498
|
+
"repository variable STACKVITALS_EVERY_CI_RUN to true so the check passes as soon as CI does.")
|
|
499
|
+
if pr_comments or verdict_check:
|
|
500
|
+
try:
|
|
501
|
+
out["comment"] = _pr_feedback(recent, latest, payload, api, data, result, repo,
|
|
502
|
+
set(quarantine_list(result, settings, decided, now)) if quarantine else None,
|
|
503
|
+
comments=pr_comments, verdict=verdict_check, notes=notes,
|
|
504
|
+
min_patch=((settings or {}).get("ci") or {}).get("coverage", {}).get("min_patch", 0.0),
|
|
505
|
+
logs=logs, settings=settings, private=bool(info.get("private")),
|
|
506
|
+
decided=decided)
|
|
507
|
+
except (APIError, OutOfBudget) as exc:
|
|
508
|
+
notes.append(f"Could not comment on pull requests ({exc}); add `pull-requests: write` to the workflow permissions.")
|
|
509
|
+
if issues:
|
|
510
|
+
try:
|
|
511
|
+
counts = sync_issues(api, result, mention_owners=mention_owners, assign_owners=assign_owners,
|
|
512
|
+
linear=bool((((settings or {}).get("connectors") or {}).get("linear") or {}).get("teams")))
|
|
513
|
+
notes.append(f"Flaky-test issues: {counts['created']} opened, {counts['updated']} updated, {counts['closed']} closed, "
|
|
514
|
+
f"{counts['reopened']} reopened, {counts['kept_closed']} left closed as not planned.")
|
|
515
|
+
except (APIError, OutOfBudget) as exc:
|
|
516
|
+
notes.append(f"Could not update flaky-test issues ({exc}); add `issues: write` to the workflow permissions.")
|
|
517
|
+
if tickets.get("tickets"):
|
|
518
|
+
notes.append(f"{tickets['tickets'].title()} tickets: {len(tickets.get('opened') or [])} opened, "
|
|
519
|
+
f"{len(tickets.get('closed') or [])} closed" + (f"; {tickets['problem']}" if tickets.get("problem") else "") + ".")
|
|
520
|
+
auto = (settings or {}).get("automation") or {}
|
|
521
|
+
if auto.get("fix_requests"):
|
|
522
|
+
from stackvitals.ci.selfheal import sync_fix_requests
|
|
523
|
+
try:
|
|
524
|
+
fixes = sync_fix_requests(api, result, assign="copilot" if auto.get("fix_by_copilot") else None,
|
|
525
|
+
branch=info.get("default_branch"))
|
|
526
|
+
out["fix_requests"] = fixes
|
|
527
|
+
notes.append(f"Fix requests: {fixes['created']} opened ({fixes['assigned']} assigned to Copilot), "
|
|
528
|
+
f"{fixes['verified']} verified fixed and closed, {fixes['reopened']} reopened, {fixes['updated']} "
|
|
529
|
+
f"updated" + (f", {fixes['held']} waiting for the next run" if fixes["held"] else "") + ".")
|
|
530
|
+
except (APIError, OutOfBudget) as exc:
|
|
531
|
+
notes.append(f"Could not update fix requests ({exc}); add `issues: write` to the workflow permissions.")
|
|
532
|
+
from stackvitals.core import chat
|
|
533
|
+
# Each owning team with its own webhook hears about its tests; the rest go to the repo's webhook.
|
|
534
|
+
for hook, names in chat.route(owners_of(fresh, result), {**env, "STACKVITALS_WEBHOOK": webhook or ""}).items():
|
|
535
|
+
app = chat.app_url(repo, info, "ci")
|
|
536
|
+
links = [("Open CI health", app)] if app else [("Workflow runs", f"https://github.com/{repo}/actions")]
|
|
537
|
+
# The data is committed by now; a chat service being down must not fail the run, only be reported.
|
|
538
|
+
failed = send_alert(hook, alert_message(names, repo, result, links))
|
|
539
|
+
if failed:
|
|
540
|
+
notes.append(failed)
|
|
541
|
+
summary = env.get("GITHUB_STEP_SUMMARY")
|
|
542
|
+
sharing = (settings or {}).get("benchmarks") or {}
|
|
543
|
+
if sharing.get("share"):
|
|
544
|
+
# Opt-in: a link a person opens to file this month's rounded figures; the Action itself sends nothing.
|
|
545
|
+
from stackvitals.ci import share
|
|
546
|
+
notes.append(share.summary_line(share_doc))
|
|
547
|
+
if summary:
|
|
548
|
+
note = "" if stats["complete"] else "\n\nStill filling in older history (GitHub's hourly limit); the next run continues."
|
|
549
|
+
with open(summary, "a", encoding="utf-8") as f:
|
|
550
|
+
f.write(render_markdown(result, repo) + note + "".join(f"\n\n{n}" for n in notes) + "\n")
|
|
551
|
+
ws.after_commit(after_commit, LABEL)
|
|
552
|
+
return out
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def run_action(env: dict, now: datetime | None = None, days: int = 90, window_days: int = 30,
|
|
556
|
+
retention_days: int = 180, budget: int = 800, exclude: list[str] | None = None,
|
|
557
|
+
make_api=ActionsAPI, tests: bool = True, artifact_pattern: str | None = None,
|
|
558
|
+
pr_comments: bool = True, rerun_flaky: bool = False, quarantine: bool = False,
|
|
559
|
+
issues: bool = False, webhook: str | None = None, mention_owners: bool = False, workspace=None,
|
|
560
|
+
assign_owners: bool = False, verdict_check: bool = False) -> dict:
|
|
561
|
+
"""One StackVitals CI run inside GitHub Actions: collect, store in the data branch, rebuild the dashboard.
|
|
562
|
+
With a shared workspace the job commits once for every module; alone, this does its own checkout and commit."""
|
|
563
|
+
from stackvitals.core.workspace import run_job
|
|
564
|
+
now = now or datetime.now(timezone.utc)
|
|
565
|
+
|
|
566
|
+
def step(ws):
|
|
567
|
+
return ci_step(ws, env, now, days, window_days, retention_days, budget, exclude, make_api, tests, artifact_pattern,
|
|
568
|
+
pr_comments, rerun_flaky, quarantine, issues, webhook, mention_owners, assign_owners, verdict_check)
|
|
569
|
+
if workspace is not None:
|
|
570
|
+
return step(workspace)
|
|
571
|
+
from stackvitals.core.settings import prepare
|
|
572
|
+
job = run_job(env, [(LABEL, step)], now, message=f"stackvitals: CI data {now:%Y-%m-%d %H:%M} UTC", catch=False,
|
|
573
|
+
prepare=prepare(env, make_api))
|
|
574
|
+
return {**job["results"][LABEL], "pushed": job["outcome"] == "pushed", "unchanged": job["outcome"] == "unchanged"}
|