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,365 @@
|
|
|
1
|
+
"""Insights that need two modules' history at once. The first: how long ready pull requests sat with failing checks on
|
|
2
|
+
their newest commit before they merged, each wait traced to the run that turned the PR red and the one that cleared it."""
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from stackvitals.ci.analyze import _job_reads_pr
|
|
7
|
+
from stackvitals.core import metrics
|
|
8
|
+
from stackvitals.core.actors import kind_of
|
|
9
|
+
from stackvitals.core.joins import coverage_start, runs_by_pr
|
|
10
|
+
from stackvitals.core.stats import median
|
|
11
|
+
from stackvitals.core.time import iso, secs
|
|
12
|
+
|
|
13
|
+
FAIL = {"failure", "timed_out", "startup_failure"}
|
|
14
|
+
# No verdict on the code: stopped by a newer push, waiting for a maintainer's approval, or never ran.
|
|
15
|
+
NO_VERDICT = {"cancelled", "action_required", "stale", "skipped", None, ""}
|
|
16
|
+
DEFINITION = ("Time a ready pull request spent with a failing check on its newest commit: from the first failing job "
|
|
17
|
+
"until a new commit, a passing re-run on the same code, or the merge. GitHub Actions runs of the PR's own code only "
|
|
18
|
+
"(pull_request_target runs use the base branch's workflow and are left out, as are checks that read the PR's "
|
|
19
|
+
"title, description or labels); time spent as a draft never counts.")
|
|
20
|
+
TOP = 5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _blamed(failed: list[dict]) -> list[dict]:
|
|
24
|
+
"""The failed jobs that did the failing: a job that started only after every other failed job ended is a summary job
|
|
25
|
+
(it waits on them and fails when any did), so it is left out when others failed too."""
|
|
26
|
+
if len(failed) < 2:
|
|
27
|
+
return failed
|
|
28
|
+
real = [j for j in failed if not (j.get("st") and all(o.get("en") and j["st"] >= o["en"] for o in failed if o is not j))]
|
|
29
|
+
return real or failed
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _attempts(runs: list[dict], jobs: list[dict], meta: dict) -> list[dict]:
|
|
33
|
+
"""Each finished run attempt with a verdict: when it ended and which of its jobs failed."""
|
|
34
|
+
by_attempt = defaultdict(list)
|
|
35
|
+
for j in jobs:
|
|
36
|
+
by_attempt[(j["run"], j.get("att", 1))].append(j)
|
|
37
|
+
out = []
|
|
38
|
+
for r in runs:
|
|
39
|
+
mine = by_attempt.get((r["id"], r["att"]), [])
|
|
40
|
+
reads_pr = meta.get(r.get("path") or "", ())
|
|
41
|
+
judged = [j for j in mine if not _job_reads_pr(j.get("n") or "", reads_pr)]
|
|
42
|
+
if mine and not judged:
|
|
43
|
+
continue # only checks of the PR's own text ran
|
|
44
|
+
if judged:
|
|
45
|
+
bad = [j for j in judged if j.get("c") in FAIL]
|
|
46
|
+
failed = sorted({j.get("n") or "?" for j in _blamed(bad)})
|
|
47
|
+
no_verdict = {j.get("c") for j in judged} <= NO_VERDICT
|
|
48
|
+
done = max((j["en"] for j in judged if j.get("en")), default=r.get("up"))
|
|
49
|
+
# GitHub shows the PR red as soon as one job fails, long before the whole run ends.
|
|
50
|
+
done = min((j["en"] for j in bad if j.get("en")), default=done) if bad else done
|
|
51
|
+
else:
|
|
52
|
+
failed = ["(run)"] if r.get("c") in FAIL else []
|
|
53
|
+
no_verdict = False
|
|
54
|
+
done = r.get("up")
|
|
55
|
+
if not done or (not failed and (no_verdict or r.get("c") in NO_VERDICT)):
|
|
56
|
+
continue
|
|
57
|
+
out.append({"run": r["id"], "attempt": r["att"], "sha": r.get("sha"), "wf": r.get("wf") or r.get("path"),
|
|
58
|
+
"workflow": r.get("wfn") or r.get("path"), "done": done, "failed": failed})
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def draft_spans(pr: dict) -> list[tuple[str, str]]:
|
|
63
|
+
"""When the PR was a draft, from its ready/draft toggles: it starts as a draft when its first toggle is "ready"
|
|
64
|
+
(or it has none and is a draft now)."""
|
|
65
|
+
toggles = sorted((e for e in pr.get("ev") or [] if e[0] in ("ready", "draft") and len(e) > 1 and e[1]), key=lambda e: e[1])
|
|
66
|
+
draft = toggles[0][0] == "ready" if toggles else bool(pr.get("draft"))
|
|
67
|
+
spans, since = [], pr["cr"] if draft else None
|
|
68
|
+
for kind, when, *_ in toggles:
|
|
69
|
+
if kind == "draft" and since is None:
|
|
70
|
+
since = when
|
|
71
|
+
elif kind == "ready" and since is not None:
|
|
72
|
+
spans.append((since, when))
|
|
73
|
+
since = None
|
|
74
|
+
if since is not None:
|
|
75
|
+
spans.append((since, "9999"))
|
|
76
|
+
return spans
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ready_parts(start: str, end: str, drafts: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
|
80
|
+
# The parts of [start, end] when the PR was not a draft.
|
|
81
|
+
parts, cur = [], start
|
|
82
|
+
for a, b in drafts:
|
|
83
|
+
if b <= cur or a >= end:
|
|
84
|
+
continue
|
|
85
|
+
if a > cur:
|
|
86
|
+
parts.append((cur, a))
|
|
87
|
+
cur = max(cur, b)
|
|
88
|
+
if cur < end:
|
|
89
|
+
parts.append((cur, end))
|
|
90
|
+
return parts
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def red_intervals(pr: dict, runs: list[dict], jobs: list[dict], meta: dict, end: str) -> list[dict]:
|
|
94
|
+
"""The waits of one PR with failing checks on its newest commit, each with the run that started it and what ended it."""
|
|
95
|
+
first_run = {}
|
|
96
|
+
for r in runs:
|
|
97
|
+
if r.get("sha") and r.get("cr"):
|
|
98
|
+
seen = first_run.get(r["sha"])
|
|
99
|
+
if seen is None or (r["cr"], r["id"]) < seen:
|
|
100
|
+
first_run[r["sha"]] = (r["cr"], r["id"])
|
|
101
|
+
# A commit becomes the PR's newest when its first run is created (the push), not when it was authored.
|
|
102
|
+
events = [(cr, 0, ("head", sha, rid)) for sha, (cr, rid) in first_run.items()]
|
|
103
|
+
events += [(a["done"], 1, ("done", a)) for a in _attempts(runs, jobs, meta)]
|
|
104
|
+
events.sort(key=lambda e: (e[0], e[1]))
|
|
105
|
+
stop = pr.get("mg") or pr.get("cl") or end
|
|
106
|
+
latest, head, waits, current = {}, None, [], None
|
|
107
|
+
for when, _, event in events:
|
|
108
|
+
if when > stop:
|
|
109
|
+
break
|
|
110
|
+
if event[0] == "head":
|
|
111
|
+
head = event[1]
|
|
112
|
+
else:
|
|
113
|
+
a = event[1]
|
|
114
|
+
latest[(a["sha"], a["wf"])] = a
|
|
115
|
+
failing = [a for (sha, _wf), a in latest.items() if sha == head and a["failed"]]
|
|
116
|
+
if failing and current is None:
|
|
117
|
+
cause = event[1] if event[0] == "done" and event[1]["failed"] else min(failing, key=lambda a: a["done"])
|
|
118
|
+
current = {"from": when, "failed": {"run": cause["run"], "attempt": cause["attempt"], "workflow": cause["workflow"],
|
|
119
|
+
"jobs": cause["failed"]}}
|
|
120
|
+
elif not failing and current is not None:
|
|
121
|
+
if event[0] == "head":
|
|
122
|
+
current.update(to=when, ended_by="push", cleared={"sha": event[1], "run": event[2]})
|
|
123
|
+
else:
|
|
124
|
+
current.update(to=when, ended_by="rerun", cleared={"run": event[1]["run"], "attempt": event[1]["attempt"]})
|
|
125
|
+
waits.append(current)
|
|
126
|
+
current = None
|
|
127
|
+
if current is not None:
|
|
128
|
+
current.update(to=stop, ended_by="merged" if pr.get("mg") else "closed" if pr.get("cl") else "open", cleared=None)
|
|
129
|
+
waits.append(current)
|
|
130
|
+
out, drafts = [], draft_spans(pr)
|
|
131
|
+
for w in waits:
|
|
132
|
+
# A draft's failing checks are its author's work in progress: only the time it was ready counts.
|
|
133
|
+
parts = _ready_parts(w["from"], w["to"], drafts)
|
|
134
|
+
hours = sum(secs(a, b) for a, b in parts) / 3600
|
|
135
|
+
if parts and hours > 0:
|
|
136
|
+
out.append({**w, "from": parts[0][0], "hours": round(hours, 2)})
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def red_ci(prs: list[dict], ci: dict, now: datetime, window_days: int = 90, since: str | None = None, meta: dict | None = None,
|
|
141
|
+
state: dict | None = None) -> dict:
|
|
142
|
+
"""How long people's merged PRs sat with failing checks, over the part of the CI history with no gaps."""
|
|
143
|
+
end = iso(now)
|
|
144
|
+
window = iso(now - timedelta(days=window_days))
|
|
145
|
+
start = since or coverage_start(state or {}, ci.get("runs") or [])
|
|
146
|
+
start = max(start, window) if start else window
|
|
147
|
+
if meta is None:
|
|
148
|
+
meta = {path: set(v.get("jobs") or []) for path, v in ((state or {}).get("triggers") or {}).items()}
|
|
149
|
+
by_pr = runs_by_pr(ci.get("runs") or [], prs)
|
|
150
|
+
jobs_by_run = defaultdict(list)
|
|
151
|
+
for j in ci.get("jobs") or []:
|
|
152
|
+
jobs_by_run[j["run"]].append(j)
|
|
153
|
+
rows, no_checks, open_red = [], 0, []
|
|
154
|
+
for p in prs:
|
|
155
|
+
if kind_of(p.get("au"), p.get("at")) != "person" or p["cr"] < start:
|
|
156
|
+
continue
|
|
157
|
+
runs = by_pr.get(p["n"], [])
|
|
158
|
+
mine = [j for rid in {r["id"] for r in runs} for j in jobs_by_run.get(rid, [])]
|
|
159
|
+
if p.get("mg"):
|
|
160
|
+
if p["mg"] > end:
|
|
161
|
+
continue
|
|
162
|
+
if not runs:
|
|
163
|
+
no_checks += 1
|
|
164
|
+
continue
|
|
165
|
+
waits = red_intervals(p, runs, mine, meta, end)
|
|
166
|
+
rows.append({"pr": p["n"], "red_h": round(sum(w["hours"] for w in waits), 2),
|
|
167
|
+
"rerun_h": round(sum(w["hours"] for w in waits if w["ended_by"] == "rerun"), 2),
|
|
168
|
+
"cycle_h": (secs(p.get("cr"), p["mg"]) or 0) / 3600, "waits": waits})
|
|
169
|
+
elif not p.get("cl") and runs:
|
|
170
|
+
waits = red_intervals(p, runs, mine, meta, end)
|
|
171
|
+
if waits and waits[-1]["ended_by"] == "open":
|
|
172
|
+
open_red.append({"pr": p["n"], "since": waits[-1]["from"], "hours": waits[-1]["hours"]})
|
|
173
|
+
red = [r for r in rows if r["red_h"] > 0]
|
|
174
|
+
jobs = defaultdict(lambda: {"hours": 0.0, "prs": set(), "runs": []})
|
|
175
|
+
tests = defaultdict(lambda: {"hours": 0.0, "prs": set(), "runs": []})
|
|
176
|
+
failed_tests = defaultdict(set)
|
|
177
|
+
for rep in ci.get("tests") or []:
|
|
178
|
+
failed_tests[(rep["run"], rep["att"])] |= {f[0] for f in rep["failed"]}
|
|
179
|
+
for r in rows:
|
|
180
|
+
for w in r["waits"]:
|
|
181
|
+
if w["ended_by"] == "rerun":
|
|
182
|
+
# A re-run that passed on the same code: the hours are shared by the jobs that had failed.
|
|
183
|
+
for name in w["failed"]["jobs"]:
|
|
184
|
+
entry = jobs[(w["failed"]["workflow"], name)]
|
|
185
|
+
entry["hours"] += w["hours"] / len(w["failed"]["jobs"])
|
|
186
|
+
entry["prs"].add(r["pr"])
|
|
187
|
+
entry["runs"].append([w["failed"]["run"], w["failed"]["attempt"]])
|
|
188
|
+
# And by the tests those jobs reported failing, when the run kept its test reports.
|
|
189
|
+
names = failed_tests.get((w["failed"]["run"], w["failed"]["attempt"])) or set()
|
|
190
|
+
for name in names:
|
|
191
|
+
entry = tests[name]
|
|
192
|
+
entry["hours"] += w["hours"] / len(names)
|
|
193
|
+
entry["prs"].add(r["pr"])
|
|
194
|
+
entry["runs"].append([w["failed"]["run"], w["failed"]["attempt"]])
|
|
195
|
+
measures = [metrics.measure("insight.red_ci_share", hits=len(red), n=len(rows), as_of=now, window_days=window_days),
|
|
196
|
+
metrics.measure("insight.red_ci_hours", values=[r["red_h"] for r in red], as_of=now, window_days=window_days)]
|
|
197
|
+
return {"definition": DEFINITION, "since": start, "until": end, "prs": len(rows), "without_checks": no_checks,
|
|
198
|
+
"red_prs": len(red), "red_hours_total": round(sum(r["red_h"] for r in rows), 2),
|
|
199
|
+
"rerun_hours_total": round(sum(r["rerun_h"] for r in rows), 2),
|
|
200
|
+
"rerun_waits": sum(1 for r in rows for w in r["waits"] if w["ended_by"] == "rerun"),
|
|
201
|
+
"measures": {m.metric: m.to_dict() for m in measures},
|
|
202
|
+
"cycle_hours": {"red": {"median": _round(median([r["cycle_h"] for r in red])), "n": len(red)},
|
|
203
|
+
"clear": {"median": _round(median([r["cycle_h"] for r in rows if not r["red_h"]])),
|
|
204
|
+
"n": len(rows) - len(red)}},
|
|
205
|
+
"rerun_jobs": sorted(({"workflow": wf, "job": job, "hours": round(v["hours"], 2), "prs": len(v["prs"]),
|
|
206
|
+
"runs": v["runs"][-5:]}
|
|
207
|
+
for (wf, job), v in jobs.items()), key=lambda x: -x["hours"]),
|
|
208
|
+
"rerun_tests": sorted(({"test": name, "hours": round(v["hours"], 2), "prs": len(v["prs"]),
|
|
209
|
+
"pr_list": sorted(v["prs"])[-10:], "runs": v["runs"][-5:]}
|
|
210
|
+
for name, v in tests.items()), key=lambda x: (-x["hours"], x["test"]))[:20],
|
|
211
|
+
"top": [{"pr": r["pr"], "hours": r["red_h"], "waits": r["waits"]}
|
|
212
|
+
for r in sorted(red, key=lambda r: -r["red_h"])[:TOP]],
|
|
213
|
+
"open_red": sorted(open_red, key=lambda x: x["since"])}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def evidence_chains(red: dict | None, ci: dict | None, decisions: dict, top: int = 10) -> list[dict]:
|
|
217
|
+
"""Each test whose failures held merged PRs red until a re-run passed, traced end to end: the hours and PRs it held,
|
|
218
|
+
CI Health's verdict and flake count, the owners CODEOWNERS names for its file, and the decision recorded on it."""
|
|
219
|
+
tests = {t["id"]: t for t in (ci or {}).get("tests") or []}
|
|
220
|
+
out = []
|
|
221
|
+
for r in ((red or {}).get("rerun_tests") or [])[:top]:
|
|
222
|
+
t = tests.get(r["test"]) or {}
|
|
223
|
+
d = decisions.get(f"test:{r['test']}")
|
|
224
|
+
out.append({"test": r["test"], "hours": r["hours"], "prs": r["prs"], "pr_list": r.get("pr_list") or [],
|
|
225
|
+
"state": t.get("state"), "flake_events": t.get("flake_events"), "runs": t.get("runs"),
|
|
226
|
+
"owners": t.get("owners") or [], "owners_from": t.get("owners_from"),
|
|
227
|
+
"decision": {k: d.get(k) for k in ("verb", "by", "at", "until", "reason")} if d else None})
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _round(x):
|
|
232
|
+
return None if x is None else round(x, 1)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
ENDED = {"push": "a new commit", "rerun": "a passing re-run on the same code", "merged": "the merge (still red)",
|
|
236
|
+
"closed": "closing", "open": "nothing yet"}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _plain(text, markdown: bool) -> str:
|
|
240
|
+
# Job and workflow names come from workflow files a fork's PR can change: no links, mentions or markup in summaries.
|
|
241
|
+
if not markdown:
|
|
242
|
+
return str(text)
|
|
243
|
+
from stackvitals.flow.report import _md
|
|
244
|
+
return _md(text).replace("@", "@").replace("](", "](").replace("www.", "www.")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _span(hours: float) -> str:
|
|
248
|
+
return f"{hours / 24:.1f} days" if hours >= 48 else f"{hours:g} h"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def render(out: dict, repo: str, markdown: bool = False) -> str:
|
|
252
|
+
"""The red-CI insight in a few lines: counts, ranges, the longest waits with links to the runs, open PRs red now."""
|
|
253
|
+
share, hours = out["measures"]["insight.red_ci_share"], out["measures"]["insight.red_ci_hours"]
|
|
254
|
+
head = (f"Red CI in {repo}: people's merged pull requests opened since {out['since'][:10]} that ran checks: "
|
|
255
|
+
f"{out['prs']}" + (f" ({out['without_checks']} more ran none)." if out.get("without_checks") else "."))
|
|
256
|
+
if not out["prs"]:
|
|
257
|
+
return head + " Nothing to measure yet."
|
|
258
|
+
pct = lambda x: f"{x * 100:.0f}%" # noqa: E731
|
|
259
|
+
line = f"- {out['red_prs']} of {out['prs']} spent time ready for review with a failing check on their newest commit"
|
|
260
|
+
if share["value"] is not None:
|
|
261
|
+
line += f" ({pct(share['value'])}, range {share['low'] * 100:.0f}-{pct(share['high'])})"
|
|
262
|
+
lines = [head, line + "."]
|
|
263
|
+
if out["red_prs"]:
|
|
264
|
+
line = f"- {out['red_hours_total']:g} h in all"
|
|
265
|
+
if hours["value"] is not None:
|
|
266
|
+
span = f" (range {hours['low']:.1f}-{hours['high']:.1f} h)" if hours["low"] is not None else ""
|
|
267
|
+
line += f"; a median {hours['value']:.1f} h per PR{span}"
|
|
268
|
+
lines.append(line + ".")
|
|
269
|
+
if out["rerun_waits"]:
|
|
270
|
+
jobs = "; ".join(f"{_plain(j['job'], markdown)} ({_plain(j['workflow'], markdown)}) {j['hours']:g} h on {j['prs']} "
|
|
271
|
+
f"PR{'' if j['prs'] == 1 else 's'}" for j in out["rerun_jobs"][:5])
|
|
272
|
+
lines.append(f"- {out['rerun_waits']} of those waits ended with a re-run that passed on the same code: "
|
|
273
|
+
f"{out['rerun_hours_total']:g} h. {jobs}.")
|
|
274
|
+
if out.get("rerun_tests"):
|
|
275
|
+
named = "; ".join(f"{_plain(t['test'], markdown)} {t['hours']:g} h on {t['prs']} PR{'' if t['prs'] == 1 else 's'}"
|
|
276
|
+
for t in out["rerun_tests"][:5])
|
|
277
|
+
lines.append(f"- Flaky tests behind those re-runs: {named}.")
|
|
278
|
+
cycle = out["cycle_hours"]
|
|
279
|
+
if cycle["red"]["median"] is not None and cycle["clear"]["median"] is not None:
|
|
280
|
+
lines.append(f"- PRs that went red took a median {cycle['red']['median']:g} h from opening to merge "
|
|
281
|
+
f"(n={cycle['red']['n']}), the others {cycle['clear']['median']:g} h (n={cycle['clear']['n']}): "
|
|
282
|
+
f"an association, not a cause.")
|
|
283
|
+
for t in out["top"]:
|
|
284
|
+
w = max(t["waits"], key=lambda x: x["hours"])
|
|
285
|
+
f = w["failed"]
|
|
286
|
+
jobs = ", ".join(_plain(j, markdown) for j in f["jobs"][:3]) + (", ..." if len(f["jobs"]) > 3 else "")
|
|
287
|
+
lines.append(f"- #{t['pr']}: {t['hours']:g} h red; the longest wait, {w['hours']:g} h, began when "
|
|
288
|
+
f"{_plain(f['workflow'], markdown)} failed ({jobs}) in https://github.com/{repo}/actions/runs/{f['run']}/"
|
|
289
|
+
f"attempts/{f['attempt']} and ended with {ENDED[w['ended_by']]}.")
|
|
290
|
+
if out["open_red"]:
|
|
291
|
+
lines.append("Open PRs red right now: " + ", ".join(f"#{o['pr']} for {_span(o['hours'])}" for o in out["open_red"][:10]) + ".")
|
|
292
|
+
lines.append(f"Definition: {out['definition']}")
|
|
293
|
+
return "\n".join(lines)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def insights_step(ws, env: dict, now: datetime) -> dict | None:
|
|
297
|
+
"""The Action's insights from what the job's checkout holds: what to fix first across modules (Team Flow is enough),
|
|
298
|
+
and the red-CI insight when CI Health's runs are there too."""
|
|
299
|
+
import html
|
|
300
|
+
import json
|
|
301
|
+
|
|
302
|
+
from stackvitals import shell
|
|
303
|
+
from stackvitals.ci.store import DataStore
|
|
304
|
+
from stackvitals.core import findings
|
|
305
|
+
from stackvitals.core.store import accept, published, write_if_changed
|
|
306
|
+
from stackvitals.flow.store import FlowStore
|
|
307
|
+
if not (ws.path / "flow" / "closed").is_dir():
|
|
308
|
+
return None
|
|
309
|
+
from stackvitals.core.decisions import visible
|
|
310
|
+
flow = visible(ws.path, ws.cache.get("flow") or FlowStore(ws.path).load(), now.date())
|
|
311
|
+
prs = flow["open"] + flow["closed"]
|
|
312
|
+
out, bumps = None, []
|
|
313
|
+
if (ws.path / "ci" / "days").is_dir():
|
|
314
|
+
ci_store = DataStore(ws.path)
|
|
315
|
+
ci, state = ws.cache.get("ci") or ci_store.load(), ci_store.state()
|
|
316
|
+
out = red_ci(prs, ci, now, state=state)
|
|
317
|
+
meta = {path: set(v.get("jobs") or []) for path, v in (state.get("triggers") or {}).items()}
|
|
318
|
+
bumps = findings.bump_reds(prs, ci, now, meta, since=out["since"])
|
|
319
|
+
docs = ws.path / "docs"
|
|
320
|
+
docs.mkdir(parents=True, exist_ok=True)
|
|
321
|
+
|
|
322
|
+
def read(name):
|
|
323
|
+
try:
|
|
324
|
+
return accept(json.loads((docs / name).read_text(encoding="utf-8")))
|
|
325
|
+
except (OSError, ValueError):
|
|
326
|
+
return None
|
|
327
|
+
ledger = None
|
|
328
|
+
if bumps:
|
|
329
|
+
from stackvitals.deps.evidence import load_ledger
|
|
330
|
+
# How the same updates went across the crawl: only read when an update here sat red.
|
|
331
|
+
ledger = load_ledger(env.get("STACKVITALS_TOKEN"), ws.work / "deps")
|
|
332
|
+
found = findings.build(ws.repo, out, ws.cache.get("delivery") or read("delivery.json"), read("flow.json"), bumps, now, ledger,
|
|
333
|
+
began=findings.began_after(read("data.json"), prs))
|
|
334
|
+
# How long each finding has been around, and what people decided about it (acknowledged, snoozed).
|
|
335
|
+
from stackvitals.core.decisions import Store as Decisions
|
|
336
|
+
days_file = ws.path / "insights" / "ledger.json"
|
|
337
|
+
try:
|
|
338
|
+
before = json.loads(days_file.read_text(encoding="utf-8"))
|
|
339
|
+
except (OSError, ValueError):
|
|
340
|
+
before = {}
|
|
341
|
+
decided = Decisions(ws.path).state(now.date())
|
|
342
|
+
aside = {k.split(":", 1)[1] for k, d in decided.items() if k.startswith("finding:") and d.get("verb") in findings.SET_ASIDE}
|
|
343
|
+
days = findings.update_ledger(before, found, now, aside)
|
|
344
|
+
if days != before:
|
|
345
|
+
days_file.parent.mkdir(parents=True, exist_ok=True)
|
|
346
|
+
days_file.write_text(json.dumps(days, indent=1, sort_keys=True), encoding="utf-8")
|
|
347
|
+
found = findings.annotate(found, days, decided, now)
|
|
348
|
+
chains = evidence_chains(out, read("data.json"), decided)
|
|
349
|
+
red_html = ("<h2>Red checks on pull requests</h2>" + "".join(f"<p>{html.escape(line)}</p>" for line in render(out, ws.repo).splitlines())
|
|
350
|
+
if out and out["prs"] else "")
|
|
351
|
+
write_if_changed(docs / "insights.json", published({"generated": iso(now), "red_ci": out, "findings": found,
|
|
352
|
+
"chains": chains, "track_record": findings.track_record(days, now),
|
|
353
|
+
"opportunity": findings.opportunity(found)}),
|
|
354
|
+
also=lambda: (docs / "insights.html").write_text(findings.render_html(
|
|
355
|
+
found, ws.repo, now, shell.present(docs) | {"insights.html"}, red_html), encoding="utf-8"))
|
|
356
|
+
|
|
357
|
+
def after_commit():
|
|
358
|
+
summary = env.get("GITHUB_STEP_SUMMARY")
|
|
359
|
+
if summary and (found or (out and out["prs"])):
|
|
360
|
+
with open(summary, "a", encoding="utf-8") as f:
|
|
361
|
+
f.write(findings.render_markdown(found, ws.repo))
|
|
362
|
+
if out and out["prs"]:
|
|
363
|
+
f.write("### StackVitals insights\n" + render(out, ws.repo, markdown=True) + "\n")
|
|
364
|
+
ws.after_commit(after_commit, "Insights", first=True)
|
|
365
|
+
return {"prs": out["prs"] if out else 0, "red_prs": out["red_prs"] if out else 0, "findings": len(found)}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""`stackvitals insights owner/repo`: findings that need two modules' history at once, for any repo, from your machine."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from stackvitals.ci import cli as ci_cli
|
|
9
|
+
from stackvitals.ci.api import APIError, OutOfBudget
|
|
10
|
+
from stackvitals.core import services
|
|
11
|
+
from stackvitals.core.insights import render
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv: list[str] | None = None, tok: str | None = None) -> int:
|
|
15
|
+
parser = argparse.ArgumentParser(prog="stackvitals insights",
|
|
16
|
+
description="What to fix first across modules, and how long merged pull requests sat "
|
|
17
|
+
"with failing checks, traced to the runs.")
|
|
18
|
+
parser.add_argument("repo", help="owner/name")
|
|
19
|
+
parser.add_argument("--days", type=int, default=30, help="CI history to read when the repo doesn't run StackVitals (max 90)")
|
|
20
|
+
parser.add_argument("--json", action="store_true")
|
|
21
|
+
args = parser.parse_args(argv)
|
|
22
|
+
tok = tok or ci_cli.token()
|
|
23
|
+
if not tok:
|
|
24
|
+
print("A GitHub token is needed. Set GITHUB_TOKEN, or run `gh auth login`.", file=sys.stderr)
|
|
25
|
+
return 1
|
|
26
|
+
days = max(1, min(args.days, 90))
|
|
27
|
+
log = lambda line: print(line, file=sys.stderr, flush=True) # noqa: E731
|
|
28
|
+
try:
|
|
29
|
+
found, _ = services.findings(args.repo, days, tok, ci_cli.home(), log=log)
|
|
30
|
+
out, source = services.red_ci(args.repo, days, tok, ci_cli.home(), log=log)
|
|
31
|
+
except (APIError, OutOfBudget, httpx.HTTPError) as exc:
|
|
32
|
+
print(f"Could not read {args.repo}: {exc}", file=sys.stderr)
|
|
33
|
+
return 1
|
|
34
|
+
if args.json:
|
|
35
|
+
print(json.dumps({"repo": args.repo, "source": source, "findings": found, "red_ci": out}, indent=1))
|
|
36
|
+
return 0
|
|
37
|
+
print("What to fix first:" if found else "What to fix first: nothing stands out yet.")
|
|
38
|
+
for k, f in enumerate(found[:10], 1):
|
|
39
|
+
links = ", ".join(e["url"] for e in f["evidence"][:3] if e["url"].startswith("https://"))
|
|
40
|
+
print(f" {k}. {f['title']} {f['action'] or ''} (n={f['n']}; {f['hours']} h at stake){' ' + links if links else ''}")
|
|
41
|
+
print()
|
|
42
|
+
print(render(out, args.repo) + f"\n(Source: {source}.)")
|
|
43
|
+
return 0
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""docs/install.md: the workflow file itself, which module ids exist and what each needs, and every Action input, built
|
|
2
|
+
from the template `stackvitals ci init` prints and from action.yml, so the page can't drift from either."""
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
from stackvitals.ci.cli import ACTION_REF, workflow_template
|
|
6
|
+
from stackvitals.portal.cli import WORKFLOW as PORTAL_WORKFLOW
|
|
7
|
+
|
|
8
|
+
# id, what it gives you, when it runs, what it needs besides itself
|
|
9
|
+
MODULES = [
|
|
10
|
+
("ci", "CI Health: flaky and slowing jobs and tests, the PR verdict comment and check, test reports, owners",
|
|
11
|
+
"after each CI run finishes, and every 3 hours", "nothing; alone is fine"),
|
|
12
|
+
("flow", "Team Flow, and with it the Delivery page (cycle time, DORA, AI-assisted work, investment) and Insights "
|
|
13
|
+
"(fix first, alerts, the weekly digest)",
|
|
14
|
+
"the schedule and manual runs", "nothing; Delivery's CI parts (failed deployments from CI, merge-queue flakes) and "
|
|
15
|
+
"Insights' red-CI findings also need `ci`"),
|
|
16
|
+
("deps", "Dependency Steward: known vulnerabilities and malware in every lockfile, the check on PRs that change a lockfile, runtimes, and the weekly supply chain page",
|
|
17
|
+
"after each CI run (with `ci`) and every 3 hours for vulnerabilities; pull requests that change a lockfile; weekly for the supply chain", "nothing; alone is fine"),
|
|
18
|
+
("community", "Community: newcomers, first answers, who carries the work, stars and traffic",
|
|
19
|
+
"the schedule and manual runs", "nothing; it reads the PRs itself when `flow` isn't on"),
|
|
20
|
+
("portal", "Portal: the org's catalog, owners and scorecards",
|
|
21
|
+
"the schedule, from the org's portal repo with its own workflow (below)", "a portal repo; an org App for private repos"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _inputs(action_yml: str) -> list[str]:
|
|
26
|
+
inputs = (yaml.safe_load(action_yml) or {}).get("inputs") or {}
|
|
27
|
+
rows = ["| Input | Default | What it does |", "|---|---|---|"]
|
|
28
|
+
for name, spec in inputs.items():
|
|
29
|
+
default = spec.get("default")
|
|
30
|
+
shown = f"`{default}`" if default not in (None, "") else ""
|
|
31
|
+
rows.append(f"| `{name}` | {shown} | {' '.join(str(spec.get('description') or '').split())} |")
|
|
32
|
+
return rows
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def markdown(action_yml: str) -> str:
|
|
36
|
+
owner_repo = ACTION_REF.split("@", 1)[0]
|
|
37
|
+
lines = ["# Install", "",
|
|
38
|
+
f"StackVitals is a GitHub Action, `uses: {ACTION_REF}`, run by one workflow file in your repo. The file below "
|
|
39
|
+
"is exactly what `stackvitals ci init owner/repo` prints (that command also fills in your CI workflows' "
|
|
40
|
+
"names) and what the app's Setup page commits for you. Save it as `.github/workflows/stackvitals.yml`; "
|
|
41
|
+
"committing it starts the first run. No settings file is needed: a missing `.github/stackvitals.toml` means "
|
|
42
|
+
"the defaults ([settings.md](settings.md)).", "",
|
|
43
|
+
"## Modules", "",
|
|
44
|
+
"Pick modules with the `modules` input, comma separated. To run one alone, list only it, for example "
|
|
45
|
+
"`modules: ci`. Delivery and Insights have no id of their own: they come with `flow`.", "",
|
|
46
|
+
"| Id | What you get | Runs on | Needs |", "|---|---|---|---|"]
|
|
47
|
+
lines += [f"| `{i}` | {what} | {when} | {needs} |" for i, what, when, needs in MODULES]
|
|
48
|
+
lines += ["", "The default is `ci,flow,deps,community`. `stackvitals ci init --modules ci,deps` (or the app's Setup "
|
|
49
|
+
"picker) writes a file with only those modules' triggers and permissions, each lowered to read where nothing "
|
|
50
|
+
"of theirs writes; turning on a writing switch later (like `deps-comments`, which needs `pull-requests: write`) "
|
|
51
|
+
"means raising that permission too.", "",
|
|
52
|
+
"## The workflow file", "",
|
|
53
|
+
"Change `workflows: [\"CI\"]` to the `name:` lines of your CI workflows (the names, not the file names): "
|
|
54
|
+
"`workflow_run` matches names. The permissions are what every default switch needs; each comment says what "
|
|
55
|
+
"to lower to turn a part off. Team Flow's automation (review requests, size labels) and PR comments need "
|
|
56
|
+
"`pull-requests: write`; the weekly digest, commands and flaky-test issues need `issues: write`. A settings "
|
|
57
|
+
"change applied from the app opens a pull request, which needs the repo setting \"Allow GitHub Actions to "
|
|
58
|
+
"create and approve pull requests\" (Settings, Actions, General).", "",
|
|
59
|
+
"```yaml", workflow_template().rstrip(), "```", "",
|
|
60
|
+
"## The portal workflow", "",
|
|
61
|
+
"Portal runs in the org's own portal repo (private is best) with this file, which "
|
|
62
|
+
"`stackvitals portal init` prints; `stackvitals portal check ORG` says what your token can read first.", "",
|
|
63
|
+
"```yaml", PORTAL_WORKFLOW.format(ref=ACTION_REF).rstrip(), "```", "",
|
|
64
|
+
"## Every input", "",
|
|
65
|
+
f"From [action.yml](https://github.com/{owner_repo}/blob/main/action.yml). Inputs use hyphens "
|
|
66
|
+
"(`rerun-flaky`); the same switches in `.github/stackvitals.toml` use underscores (`rerun_flaky`), and the "
|
|
67
|
+
"file wins when both are set.", ""]
|
|
68
|
+
lines += _inputs(action_yml)
|
|
69
|
+
return "\n".join(lines) + "\n"
|