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,670 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from mcp.server.mcpserver import MCPServer
|
|
9
|
+
|
|
10
|
+
from mergescout.github.graphql import GraphQLError
|
|
11
|
+
from mergescout.mcp_server import App
|
|
12
|
+
from mergescout.mcp_server import build_server as build_contribute
|
|
13
|
+
from stackvitals.ci import cli as ci_cli
|
|
14
|
+
from stackvitals.ci.action import BRANCH
|
|
15
|
+
from stackvitals.ci.api import ActionsAPI, APIError, OutOfBudget
|
|
16
|
+
from stackvitals.ci.collect import TEST_ARTIFACTS, _reports, job_record, run_record
|
|
17
|
+
from stackvitals.ci.feedback import KNOWN, SAFE_FLAKES, TRACK_RECORD, classify, error_excerpt, failure_summary
|
|
18
|
+
from stackvitals.deps.cli import ORDER as DEPS_ORDER
|
|
19
|
+
from stackvitals.deps.cli import find as deps_find
|
|
20
|
+
from stackvitals.deps.dossier import dossier as deps_dossier
|
|
21
|
+
from stackvitals.deps.evidence import load_evidence
|
|
22
|
+
from stackvitals.deps.lockfiles import read_lockfile
|
|
23
|
+
from stackvitals.contribute.brief import build_brief, render as render_brief
|
|
24
|
+
from stackvitals.core import services
|
|
25
|
+
from stackvitals.core.store import accept
|
|
26
|
+
from stackvitals.flow import peers as flow_peers
|
|
27
|
+
from stackvitals.flow.build import build as flow_build
|
|
28
|
+
from stackvitals.flow.cli import local_gql
|
|
29
|
+
from stackvitals.flow.collect import collect as flow_collect
|
|
30
|
+
from stackvitals.flow.knowledge import codeowners_for, expertise, suggest_reviewers
|
|
31
|
+
from stackvitals.flow.preflight import collect_asks, gaps, norms
|
|
32
|
+
from stackvitals.flow.preflight import preflight as flow_preflight
|
|
33
|
+
from stackvitals.flow.store import FlowStore
|
|
34
|
+
|
|
35
|
+
DEPS_NOTE = ("Verdicts weigh what happened: upgrade outcomes in public repos, maintainer history, advisories. They never "
|
|
36
|
+
"promise a package is free of risk; read the evidence lines.")
|
|
37
|
+
ADVICE = {
|
|
38
|
+
# An agent that trusted this alone called real failures flakes (bench/accuracy/agents): say what to check first.
|
|
39
|
+
"known flaky": "This has failed and then passed with no code change often, so it may be a flake, but history alone is "
|
|
40
|
+
"no proof: on held-out public CI, 13 of 81 such failures were the change's own. Read the error: re-run it only "
|
|
41
|
+
"if it shows an outside service, the runner or a wait failing and names no file you changed; otherwise look "
|
|
42
|
+
"at your change first.",
|
|
43
|
+
"flaked before": "This has flaked before, but rarely: check whether your change caused this failure before re-running it.",
|
|
44
|
+
"already failing": "This was already failing on the default branch: your change is unlikely to be the cause.",
|
|
45
|
+
"maybe flaky": "This never flaked before, but it failed the way flaky checks do (a wait running out, a benchmark gate, a "
|
|
46
|
+
"live service): re-run it once; if it fails again, look at your change.",
|
|
47
|
+
"PR details": "A check on the pull request's title, description or labels failed: fix those on the PR, not the code; "
|
|
48
|
+
"it runs again when they change.",
|
|
49
|
+
"no history": "No flaky history for this: treat the failure as most likely caused by the change.",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _default_branch_failures(api, repo: str, data: dict, body: dict, branch: str | None) -> int:
|
|
54
|
+
"""Failed pushes of the same workflow to the default branch in the 3 days before this run, added to `data` (one
|
|
55
|
+
list request and one jobs request each), so "already failing" can be judged."""
|
|
56
|
+
if not branch or body.get("head_branch") == branch or not body.get("created_at"):
|
|
57
|
+
return 0
|
|
58
|
+
since = (datetime.fromisoformat(body["created_at"].replace("Z", "+00:00")) - timedelta(days=3)).date().isoformat()
|
|
59
|
+
listed = (api._get(f"/repos/{repo}/actions/runs", {"branch": branch, "event": "push", "status": "failure",
|
|
60
|
+
"created": f">={since}", "per_page": 30}) or {}).get("workflow_runs") or []
|
|
61
|
+
mine = [r for r in listed if r.get("path") == body.get("path") and r.get("id") != body.get("id")][:3]
|
|
62
|
+
for r in mine:
|
|
63
|
+
att = r.get("run_attempt") or 1
|
|
64
|
+
data["runs"].append(run_record(r, att))
|
|
65
|
+
data["jobs"] += [job_record(j, r["id"], att) for j in api.jobs(r["id"], att)]
|
|
66
|
+
return len(mine)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _as_of(out: dict) -> str:
|
|
70
|
+
# The time the numbers describe: the answer's own stamp, else a module's inside it, else now.
|
|
71
|
+
for value in [out, *(v for v in out.values() if isinstance(v, dict))]:
|
|
72
|
+
for key in ("generated", "as_of", "until"):
|
|
73
|
+
if isinstance(value.get(key), str):
|
|
74
|
+
return value[key]
|
|
75
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _safe(fn):
|
|
79
|
+
# Agents read plain answers better than stack traces; every answer says when and where its numbers come from.
|
|
80
|
+
def run(*args, **kwargs):
|
|
81
|
+
try:
|
|
82
|
+
out = fn(*args, **kwargs)
|
|
83
|
+
except (APIError, OutOfBudget, ValueError, httpx.HTTPError, GraphQLError) as exc:
|
|
84
|
+
return {"error": str(exc)}
|
|
85
|
+
if isinstance(out, dict) and "error" not in out:
|
|
86
|
+
out.setdefault("as_of", _as_of(out))
|
|
87
|
+
out.setdefault("sources", [out["source"]] if out.get("source") else [])
|
|
88
|
+
return out
|
|
89
|
+
run.__name__, run.__doc__ = fn.__name__, fn.__doc__
|
|
90
|
+
return run
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _brief(entry: dict, kind: str) -> dict:
|
|
94
|
+
name = entry["id"] if kind == "test" else f"{(entry.get('workflow') or '?').rsplit('/', 1)[-1]} / {entry['job']}"
|
|
95
|
+
out = {"kind": kind, "id" if kind == "test" else "job": entry["id"] if kind == "test" else entry["job"], "name": name,
|
|
96
|
+
"state": entry["state"], "flakes": entry["flake_events"], "runs": entry["runs"],
|
|
97
|
+
"last_flake": (entry.get("last_flake") or "")[:10] or None}
|
|
98
|
+
if kind == "test" and entry.get("message"):
|
|
99
|
+
out["fails_with"] = entry["message"]
|
|
100
|
+
if kind == "job" and entry.get("top_step"):
|
|
101
|
+
out["failing_step"] = entry["top_step"]
|
|
102
|
+
if entry.get("owners"):
|
|
103
|
+
out["owners"] = entry["owners"]
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _hours(secs) -> float | None:
|
|
108
|
+
return None if secs is None else round(secs / 3600, 1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# The default set: the questions a coding agent asks before and while it changes code, where history and evidence
|
|
112
|
+
# across repos beat a live search. Every other tool loads with tools="all" (`stackvitals serve --tools all`).
|
|
113
|
+
CORE_TOOLS = ("ci_failure", "is_known_flake", "ci_health", "repo_brief", "preflight", "who_should_review", "pr_queue",
|
|
114
|
+
"check_package", "scan_lockfile", "insights", "delivery_health")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build_server(app: App | None = None, ci_api=ActionsAPI, ci_home: Path | None = None,
|
|
118
|
+
ci_token: str | None = None, flow_gql=local_gql, tools: str = "all") -> MCPServer:
|
|
119
|
+
"""The suite's one MCP server: MergeScout's contribution tools plus the CI Health tools; tools="core" keeps only the
|
|
120
|
+
default set."""
|
|
121
|
+
server = build_contribute(app or App())
|
|
122
|
+
|
|
123
|
+
def result_for(repo: str, days: int) -> tuple[dict, str]:
|
|
124
|
+
# The same reader as the CLI and the brief: published file, else a fresh local copy, else a live read with tests.
|
|
125
|
+
return services.ci_result(repo, days, ci_token or ci_cli.token(), Path(ci_home or ci_cli.home()), make_api=ci_api)
|
|
126
|
+
|
|
127
|
+
def local_flow(repo: str, tok: str, months: int = 3) -> tuple[FlowStore, str]:
|
|
128
|
+
# A local copy read within the hour answers at once; otherwise read the last 3 months live.
|
|
129
|
+
now = datetime.now(timezone.utc)
|
|
130
|
+
store = FlowStore(Path(ci_home or ci_cli.home()) / "flow" / repo.replace("/", "__"))
|
|
131
|
+
last = store.state().get("last_collect")
|
|
132
|
+
if not (last and (now - datetime.fromisoformat(last.replace("Z", "+00:00"))).total_seconds() < 3600):
|
|
133
|
+
owner, name = repo.split("/", 1)
|
|
134
|
+
# Three months answer an agent quickly; the CLI reads a full year.
|
|
135
|
+
flow_collect(flow_gql(tok), store, owner, name, now, months=months)
|
|
136
|
+
source = f"read live from GitHub, PRs updated in the last {months} months"
|
|
137
|
+
else:
|
|
138
|
+
source = "local copy read within the hour"
|
|
139
|
+
return store, source
|
|
140
|
+
|
|
141
|
+
def flow_for(repo: str) -> tuple[dict, str]:
|
|
142
|
+
tok = ci_token or ci_cli.token()
|
|
143
|
+
try:
|
|
144
|
+
found = accept(json.loads(ci_api(tok, repo, max_requests=10).file("docs/flow.json", BRANCH)))
|
|
145
|
+
except (APIError, ValueError):
|
|
146
|
+
found = None
|
|
147
|
+
if found is not None:
|
|
148
|
+
return found, "stackvitals-data branch"
|
|
149
|
+
store, source = local_flow(repo, tok)
|
|
150
|
+
return flow_build(store.load(), store.repo(), datetime.now(timezone.utc), owner=repo.split("/")[0],
|
|
151
|
+
people_speed=True, asks=store.asks(),
|
|
152
|
+
bench=flow_peers.load(tok, Path(ci_home or ci_cli.home()) / "bench")), source
|
|
153
|
+
|
|
154
|
+
@server.tool()
|
|
155
|
+
def triage_pr(repo: str, pr: int = 0) -> dict:
|
|
156
|
+
"""For maintainers: the chance each open pull request from an outside contributor gets merged within 30 days,
|
|
157
|
+
with the main reasons, from a model trained on outcomes across public repos. Private to you: never stored or
|
|
158
|
+
posted. repo: owner/name; pr: one PR number, or 0 for all open outside PRs."""
|
|
159
|
+
def run():
|
|
160
|
+
from stackvitals.flow import triage as tri
|
|
161
|
+
from stackvitals.flow.cli import load_model
|
|
162
|
+
tok = ci_token or ci_cli.token()
|
|
163
|
+
model = load_model(tok)
|
|
164
|
+
if model is None:
|
|
165
|
+
raise ValueError("the triage model could not be read")
|
|
166
|
+
# Triage needs a year: the repo's rate and the author's history look back that far.
|
|
167
|
+
store, source = local_flow(repo, tok, months=12)
|
|
168
|
+
data = store.load()
|
|
169
|
+
if pr:
|
|
170
|
+
data = {"open": [p for p in data["open"] if p["n"] == int(pr)], "closed": data["closed"]}
|
|
171
|
+
out = tri.triage(data, model, datetime.now(timezone.utc))
|
|
172
|
+
return {"repo": repo, "source": source, **out}
|
|
173
|
+
return _safe(run)()
|
|
174
|
+
|
|
175
|
+
@server.tool()
|
|
176
|
+
def preflight(repo: str, paths: list[str], lines: int = 0) -> dict:
|
|
177
|
+
"""Before opening a pull request: what merged PRs touching these files usually include (tests, changelog,
|
|
178
|
+
docs, size), what this change is missing, and what maintainers most often ask for in review there.
|
|
179
|
+
repo: owner/name; paths: files the change touches; lines: lines added plus removed."""
|
|
180
|
+
def run():
|
|
181
|
+
tok = ci_token or ci_cli.token()
|
|
182
|
+
store, source = local_flow(repo, tok)
|
|
183
|
+
owner, name = repo.split("/", 1)
|
|
184
|
+
collect_asks(flow_gql(tok), store, owner, name, store.load()["closed"], cap=300)
|
|
185
|
+
data, now = store.load(), datetime.now(timezone.utc)
|
|
186
|
+
prs = data["open"] + data["closed"]
|
|
187
|
+
habits = norms(prs, paths, now)
|
|
188
|
+
return {"repo": repo, "source": source, **habits, "missing": gaps({"paths": paths, "add": lines}, habits),
|
|
189
|
+
"maintainers_ask_for": flow_preflight(prs, store.asks(), paths, now)["asks"][:5]}
|
|
190
|
+
return _safe(run)()
|
|
191
|
+
|
|
192
|
+
@server.tool()
|
|
193
|
+
def repo_brief(repo: str) -> dict:
|
|
194
|
+
"""Before working in an unfamiliar repo: how changes get merged there, measured from its history (things its files
|
|
195
|
+
don't say). Per folder: what merged PRs include, who reviews there and how fast, CODEOWNERS; CI jobs and tests
|
|
196
|
+
known to be flaky (re-run before chasing a failure); how outside PRs fare against similar repos. Every number
|
|
197
|
+
comes with its count. repo: owner/name."""
|
|
198
|
+
def run():
|
|
199
|
+
tok = ci_token or ci_cli.token()
|
|
200
|
+
store, source = local_flow(repo, tok, months=12)
|
|
201
|
+
owner, name = repo.split("/", 1)
|
|
202
|
+
collect_asks(flow_gql(tok), store, owner, name, store.load()["closed"], cap=300)
|
|
203
|
+
try:
|
|
204
|
+
ci, ci_source = result_for(repo, 14)
|
|
205
|
+
except (APIError, OutOfBudget) as exc:
|
|
206
|
+
ci, ci_source = None, f"not read ({exc})"
|
|
207
|
+
brief = build_brief(repo, store.load(), store.repo(), store.asks(), ci,
|
|
208
|
+
flow_peers.load(tok, Path(ci_home or ci_cli.home()) / "bench"), datetime.now(timezone.utc))
|
|
209
|
+
return {"brief": render_brief(brief), "facts": brief, "source": f"pull requests: {source}; CI: {ci_source}"}
|
|
210
|
+
return _safe(run)()
|
|
211
|
+
|
|
212
|
+
deps_cache = {}
|
|
213
|
+
|
|
214
|
+
def deps_evidence() -> dict | None:
|
|
215
|
+
# Read once an hour: the files are about 6 MB and change once a day.
|
|
216
|
+
if not deps_cache or time.time() - deps_cache["at"] > 3600:
|
|
217
|
+
deps_cache.update(ev=load_evidence(ci_token or ci_cli.token(), (ci_home or ci_cli.home()) / "deps"), at=time.time())
|
|
218
|
+
return deps_cache["ev"]
|
|
219
|
+
|
|
220
|
+
@server.tool()
|
|
221
|
+
def supply_chain(repo: str) -> dict:
|
|
222
|
+
"""A repo's supply chain from its dependency graph, as its weekly StackVitals run keeps it: packages by
|
|
223
|
+
ecosystem, the ones against its licence rule (and the ones a decision accepted), packages with no known licence,
|
|
224
|
+
licence changes between quarters, and projects whose OpenSSF Scorecard dropped a point or more or is under 4."""
|
|
225
|
+
def run():
|
|
226
|
+
found, source = services.supply(repo, ci_token or ci_cli.token(), make_api=ci_api)
|
|
227
|
+
return {"repo": repo, "source": source, **(found or {})}
|
|
228
|
+
return _safe(run)()
|
|
229
|
+
|
|
230
|
+
@server.tool()
|
|
231
|
+
def check_package(name: str, ecosystem: str = "") -> dict:
|
|
232
|
+
"""Before adding or upgrading a dependency: how upgrades of this npm or PyPI package went across thousands of
|
|
233
|
+
public repos, whether its maintainers still merge outside fixes, its source repo, advisories and malware records,
|
|
234
|
+
and a verdict (use / monitor / investigate, or none when evidence is thin) with the counts behind it.
|
|
235
|
+
name: "requests" or "pypi:requests"; ecosystem: "npm" or "pypi" (optional)."""
|
|
236
|
+
def run():
|
|
237
|
+
ev = deps_evidence()
|
|
238
|
+
if ev is None:
|
|
239
|
+
return {"error": "The dependency evidence couldn't be loaded (no token or files)."}
|
|
240
|
+
return {"results": deps_find(name, ev, datetime.now(timezone.utc), ecosystem or None), "note": DEPS_NOTE}
|
|
241
|
+
return _safe(run)()
|
|
242
|
+
|
|
243
|
+
@server.tool()
|
|
244
|
+
def scan_lockfile(path: str) -> dict:
|
|
245
|
+
"""Every package a lockfile pins, the ones that need a look first, each with its verdict and main reason.
|
|
246
|
+
Reads package-lock.json, npm-shrinkwrap.json, requirements*.txt, uv.lock, poetry.lock and Pipfile.lock.
|
|
247
|
+
path: the lockfile on this machine."""
|
|
248
|
+
def run():
|
|
249
|
+
ev = deps_evidence()
|
|
250
|
+
if ev is None:
|
|
251
|
+
return {"error": "The dependency evidence couldn't be loaded (no token or files)."}
|
|
252
|
+
now = datetime.now(timezone.utc)
|
|
253
|
+
pinned = dict.fromkeys((e, n) for e, n, _ in read_lockfile(Path(path)))
|
|
254
|
+
found = sorted((deps_dossier(e, n, ev, now) for e, n in pinned), key=lambda d: (DEPS_ORDER[d["verdict"]], d["package"]))
|
|
255
|
+
return {"counts": {v or "not_enough_evidence": sum(1 for d in found if d["verdict"] == v) for v in DEPS_ORDER},
|
|
256
|
+
"packages": [{"ecosystem": d["ecosystem"], "package": d["package"], "verdict": d["verdict"],
|
|
257
|
+
"reason": (d["reasons"] or [None])[0]} for d in found], "note": DEPS_NOTE}
|
|
258
|
+
return _safe(run)()
|
|
259
|
+
|
|
260
|
+
@server.tool()
|
|
261
|
+
def who_should_review(repo: str, pr: int) -> dict:
|
|
262
|
+
"""Up to 3 people who should review an open pull request: who wrote and reviewed the same areas, code owners,
|
|
263
|
+
how many reviews each already has open and how fast they usually answer. repo: owner/name; pr: its number."""
|
|
264
|
+
def run():
|
|
265
|
+
store, source = local_flow(repo, ci_token or ci_cli.token())
|
|
266
|
+
data, now = store.load(), datetime.now(timezone.utc)
|
|
267
|
+
target = next((p for p in data["open"] if p["n"] == int(pr)), None)
|
|
268
|
+
if target is None:
|
|
269
|
+
raise ValueError(f"#{pr} is not an open pull request updated in the last 3 months")
|
|
270
|
+
result = flow_build(data, store.repo(), now, owner=repo.split("/")[0], people_speed=True)
|
|
271
|
+
text = store.repo().get("codeowners")
|
|
272
|
+
owners = {path: codeowners_for(path, text) for path in target.get("paths") or []} if text else {}
|
|
273
|
+
speed = {r["reviewer"]: r["median_s"] for r in result.get("responders", [])}
|
|
274
|
+
load = {r["reviewer"]: r["open_requests"] for r in result["load"]}
|
|
275
|
+
picks = suggest_reviewers(target, expertise(data["open"] + data["closed"], now), load, owners, speed)
|
|
276
|
+
return {"repo": repo, "pr": int(pr), "source": source, "already_requested": target["rq"],
|
|
277
|
+
"suggested": [{"login": s["login"], "why": s["why"]} for s in picks]}
|
|
278
|
+
return _safe(run)()
|
|
279
|
+
|
|
280
|
+
@server.tool()
|
|
281
|
+
def pr_queue(repo: str) -> dict:
|
|
282
|
+
"""Open pull requests and who has to act next on each (reviewers, the author or maintainers), since when,
|
|
283
|
+
and whether that is longer than usual for this repo. repo: owner/name."""
|
|
284
|
+
def run():
|
|
285
|
+
result, source = flow_for(repo)
|
|
286
|
+
live = [q for q in result["queue"] if not q["stale"]]
|
|
287
|
+
return {"repo": repo, "source": source, "stale": result["stale"],
|
|
288
|
+
"waiting": [{"pr": q["n"], "title": q["title"], "author": q["author"], "outsider": q["outsider"],
|
|
289
|
+
"waiting_on": q["who"] or [q["actor"]], "actor": q["actor"], "why": q["why"],
|
|
290
|
+
"for_hours": _hours(q["waiting_s"]), "usual_hours": _hours(q["usual_s"]),
|
|
291
|
+
"overdue": q["slow"]} for q in live[:20]]}
|
|
292
|
+
return _safe(run)()
|
|
293
|
+
|
|
294
|
+
@server.tool()
|
|
295
|
+
def flow_health(repo: str) -> dict:
|
|
296
|
+
"""How a repo's pull requests flow: hours waiting for a first review, in review and to merge (GitHub's stages),
|
|
297
|
+
and how insiders and outside contributors fare (merge rate, first-review wait). repo: owner/name."""
|
|
298
|
+
def run():
|
|
299
|
+
result, source = flow_for(repo)
|
|
300
|
+
group = lambda g: {"merge_rate": result["groups"][g]["merge_rate"], # noqa: E731
|
|
301
|
+
"first_review_median_hours": _hours(result["groups"][g]["pickup"]["median_s"]),
|
|
302
|
+
"merged": result["groups"][g]["merged"]}
|
|
303
|
+
return {"repo": repo, "source": source, "window_days": result["window_days"], "open": result["open"],
|
|
304
|
+
"stages": {k: {"median_hours": _hours(v["median_s"]), "p90_hours": _hours(v["p90_s"]), "n": v["n"]}
|
|
305
|
+
for k, v in result["stages"].items()},
|
|
306
|
+
"merged_without_review": result["merged_without_review"],
|
|
307
|
+
"insiders": group("insider"), "outsiders": group("outsider"),
|
|
308
|
+
"delivery": result.get("delivery"), "compared_with_similar_repos": result.get("peers"),
|
|
309
|
+
"known_by_one_person": [{"area": a["area"], "owner": a["owner"], "share": a["share"]}
|
|
310
|
+
for a in (result.get("knowledge") or {}).get("areas", []) if a["single_owner"]][:10]}
|
|
311
|
+
return _safe(run)()
|
|
312
|
+
|
|
313
|
+
def community_for(repo: str) -> tuple[dict, str]:
|
|
314
|
+
tok = ci_token or ci_cli.token()
|
|
315
|
+
try:
|
|
316
|
+
found = accept(json.loads(ci_api(tok, repo, max_requests=10).file("docs/community.json", BRANCH)))
|
|
317
|
+
except (APIError, ValueError):
|
|
318
|
+
found = None
|
|
319
|
+
if found is not None:
|
|
320
|
+
return found, "stackvitals-data branch"
|
|
321
|
+
from stackvitals.community.cli import local_community
|
|
322
|
+
result, stats = local_community(repo, tok, datetime.now(timezone.utc), make_gql=flow_gql, make_api=ci_api,
|
|
323
|
+
root=Path(ci_home or ci_cli.home()))
|
|
324
|
+
note = "" if stats["complete"] else ", partly (GitHub's hourly limit; ask again later for more)"
|
|
325
|
+
return result, f"read live from GitHub, last 12 months{note}"
|
|
326
|
+
|
|
327
|
+
@server.tool()
|
|
328
|
+
def community_health(repo: str) -> dict:
|
|
329
|
+
"""How a repo treats newcomers: first-time contributors and how many came back, how fast outside PRs and issues
|
|
330
|
+
get a first answer (newcomers and regulars apart), who carries the merged work (people, the top person's share and
|
|
331
|
+
the bus factor, no names, with similar public repos' typical values), its community score against similar public
|
|
332
|
+
repos (with the facts behind it), star growth, and what keeps newcomers across public repos. repo: owner/name."""
|
|
333
|
+
def run():
|
|
334
|
+
result, source = community_for(repo)
|
|
335
|
+
f, st, tr = result["funnel"], result.get("stars") or {}, result.get("traffic")
|
|
336
|
+
return {"repo": repo, "source": source, "score": result.get("score"), "facts": result.get("facts"),
|
|
337
|
+
"newcomers": {"first_time": f["newcomers"], "judged": f["judged"], "came_back": f["returned"],
|
|
338
|
+
"return_rate": f["return_rate"], "regulars": f["regulars"], "maintainers": f["maintainers"]},
|
|
339
|
+
"first_answers": result["answers"], "first_answers_by_tenure": result.get("tenure"),
|
|
340
|
+
"who_carries_the_work": result.get("dependency"), "newcomers_waiting": result["waiting_total"],
|
|
341
|
+
"stars": {k: st.get(k) for k in ("gained_12w", "growth_12w", "peers")},
|
|
342
|
+
"traffic": ({k: tr[k] for k in ("days", "since", "views_total", "visitors_total", "clones_total", "referrers")} if tr
|
|
343
|
+
else "not kept (needs a token with Administration read)"),
|
|
344
|
+
"what_keeps_newcomers": result.get("lever")}
|
|
345
|
+
return _safe(run)()
|
|
346
|
+
|
|
347
|
+
@server.tool()
|
|
348
|
+
def newcomers_waiting(repo: str) -> dict:
|
|
349
|
+
"""Open pull requests and issues that are someone's first contribution to a repo and that nobody has answered yet,
|
|
350
|
+
oldest first: the replies that most help a project keep new contributors. repo: owner/name."""
|
|
351
|
+
def run():
|
|
352
|
+
result, source = community_for(repo)
|
|
353
|
+
kind = {"pr": "pull", "issue": "issues"}
|
|
354
|
+
return {"repo": repo, "source": source, "total": result["waiting_total"],
|
|
355
|
+
"waiting": [{"kind": w["kind"], "number": w["n"], "days": w["days"],
|
|
356
|
+
"url": f"https://github.com/{repo}/{kind.get(w['kind'], 'issues')}/{w['n']}"}
|
|
357
|
+
for w in result["waiting"][:30]]}
|
|
358
|
+
return _safe(run)()
|
|
359
|
+
|
|
360
|
+
def portal_for(org: str) -> tuple[dict, str]:
|
|
361
|
+
tok = ci_token or ci_cli.token()
|
|
362
|
+
try:
|
|
363
|
+
found = accept(json.loads(ci_api(tok, f"{org}/portal", max_requests=10).file("portal/catalog.json", BRANCH)))
|
|
364
|
+
except (APIError, ValueError):
|
|
365
|
+
found = None
|
|
366
|
+
if found is not None:
|
|
367
|
+
return found, f"{org}/portal {BRANCH} branch"
|
|
368
|
+
from stackvitals.portal.catalog import build_catalog
|
|
369
|
+
from stackvitals.portal.scorecard import rules_or_default, score_catalog
|
|
370
|
+
now = datetime.now(timezone.utc)
|
|
371
|
+
catalog = build_catalog(ci_api(tok, f"{org}/portal", max_requests=600), org, "local", now, None)
|
|
372
|
+
rules, exemptions, _ = rules_or_default(None)
|
|
373
|
+
score_catalog(catalog, rules, exemptions, now, None)
|
|
374
|
+
return catalog, "read live from GitHub (up to 600 requests; no published portal catalog was found)"
|
|
375
|
+
|
|
376
|
+
def _active(catalog: dict) -> list[dict]:
|
|
377
|
+
return [r for r in catalog.get("repos") or [] if not r.get("archived") and not r.get("fork")]
|
|
378
|
+
|
|
379
|
+
@server.tool()
|
|
380
|
+
def org_catalog(org: str) -> dict:
|
|
381
|
+
"""An org's repos at a glance: how many reach each scorecard level (bronze, silver, gold), owner status counts,
|
|
382
|
+
the rules that fail most often, and the typed catalog from repos' catalog-info.yaml files (kind, type, lifecycle,
|
|
383
|
+
owner checked against CODEOWNERS, system, depends on). Reads the org's Portal catalog when it publishes one.
|
|
384
|
+
org: the org name."""
|
|
385
|
+
def run():
|
|
386
|
+
from stackvitals.portal.pages import catalog_entities
|
|
387
|
+
catalog, source = portal_for(org)
|
|
388
|
+
active = _active(catalog)
|
|
389
|
+
entities, problems, files = catalog_entities(active)
|
|
390
|
+
scored = [r for r in active if r.get("score")]
|
|
391
|
+
failed = {}
|
|
392
|
+
for r in scored:
|
|
393
|
+
for res in r["score"].get("results") or []:
|
|
394
|
+
if res.get("result") == "fail":
|
|
395
|
+
failed[res["id"]] = failed.get(res["id"], 0) + 1
|
|
396
|
+
levels = {"gold": 0, "silver": 0, "bronze": 0, "below bronze": 0}
|
|
397
|
+
for r in scored:
|
|
398
|
+
levels[r["score"].get("level") or "below bronze"] += 1
|
|
399
|
+
return {"org": org, "source": source, "built_at": catalog.get("built_at"), "repos": len(active), "levels": levels,
|
|
400
|
+
"most_failed_rules": [{"rule": k, "repos": v} for k, v in sorted(failed.items(), key=lambda kv: (-kv[1], kv[0]))[:10]],
|
|
401
|
+
"repos_list": [{"repo": r["name"], "level": (r.get("score") or {}).get("level"),
|
|
402
|
+
"owners": (r.get("owners") or {}).get("status")} for r in active[:50]],
|
|
403
|
+
"catalog": {"repos_with_file": files, "problems": [f"{repo}: {p}" for repo, p in problems[:20]],
|
|
404
|
+
"entities": [{k: e.get(k) for k in ("repo", "kind", "name", "type", "lifecycle", "owner", "owner_check",
|
|
405
|
+
"system", "depends_on", "provides_apis")} for e in entities[:100]]}}
|
|
406
|
+
return _safe(run)()
|
|
407
|
+
|
|
408
|
+
@server.tool()
|
|
409
|
+
def repo_scorecard(repo: str) -> dict:
|
|
410
|
+
"""One repo's scorecard level and the evidence behind every failing rule, from its org's Portal catalog.
|
|
411
|
+
repo: owner/name."""
|
|
412
|
+
def run():
|
|
413
|
+
catalog, source = portal_for(repo.split("/")[0])
|
|
414
|
+
entry = next((r for r in catalog.get("repos") or [] if r.get("name", "").lower() == repo.lower()), None)
|
|
415
|
+
if entry is None or not entry.get("score"):
|
|
416
|
+
raise ValueError(f"{repo} isn't in its org's catalog yet (archived, a fork, or not checked yet)")
|
|
417
|
+
results = entry["score"].get("results") or []
|
|
418
|
+
return {"repo": repo, "source": source, "level": entry["score"].get("level"),
|
|
419
|
+
"failing": [{"rule": x["id"], "evidence": x["evidence"]} for x in results if x["result"] == "fail"],
|
|
420
|
+
"passing": [x["id"] for x in results if x["result"] in ("pass", "exempt")],
|
|
421
|
+
"not_available": [x["id"] for x in results if x["result"] == "not available"]}
|
|
422
|
+
return _safe(run)()
|
|
423
|
+
|
|
424
|
+
@server.tool()
|
|
425
|
+
def owners_health(org: str) -> dict:
|
|
426
|
+
"""Who owns what in an org, checked: repos whose CODEOWNERS has errors, empty teams or departed people (weak), and
|
|
427
|
+
repos without a CODEOWNERS file (with suggestions from recent committers). org: the org name."""
|
|
428
|
+
def run():
|
|
429
|
+
catalog, source = portal_for(org)
|
|
430
|
+
active = [r for r in _active(catalog) if r.get("owners")]
|
|
431
|
+
counts = {s: sum(1 for r in active if r["owners"].get("status") == s) for s in ("valid", "weak", "none")}
|
|
432
|
+
return {"org": org, "source": source, "counts": counts,
|
|
433
|
+
"weak": [{"repo": r["name"], "reasons": r["owners"].get("reasons") or []}
|
|
434
|
+
for r in active if r["owners"].get("status") == "weak"][:30],
|
|
435
|
+
"none": [{"repo": r["name"], "suggested": r["owners"].get("suggested") or []}
|
|
436
|
+
for r in active if r["owners"].get("status") == "none"][:30]}
|
|
437
|
+
return _safe(run)()
|
|
438
|
+
|
|
439
|
+
@server.tool()
|
|
440
|
+
def ci_health(repo: str, days: int = 14) -> dict:
|
|
441
|
+
"""A repo's CI health: flaky jobs and tests (failed, then passed with no code change), jobs that keep
|
|
442
|
+
failing, jobs that got slower, line coverage with each pull request's patch coverage when CI uploads a
|
|
443
|
+
coverage report, pushes green on the first try with their likely ranges, and this week against the four before
|
|
444
|
+
(only changes beyond noise are flagged; steady ones say how big a change they could have shown). repo: owner/name."""
|
|
445
|
+
def run():
|
|
446
|
+
result, source = result_for(repo, max(1, min(days, 90)))
|
|
447
|
+
flaky = lambda items: [i for i in items if i["state"] in ("flaky", "suspect")]
|
|
448
|
+
return {"repo": repo, "source": source, "window_days": result["window_days"], "runs": result["runs"],
|
|
449
|
+
"rerun_share": result["rerun_share"],
|
|
450
|
+
"flaky_tests": [_brief(t, "test") for t in flaky(result.get("tests", []))][:10],
|
|
451
|
+
"flaky_jobs": [_brief(j, "job") for j in flaky(result["jobs"])][:10],
|
|
452
|
+
"keeps_failing": [_brief(j, "job") for j in result["jobs"] if j["state"] == "broken"]
|
|
453
|
+
+ [_brief(t, "test") for t in result.get("tests", []) if t["state"] == "broken"],
|
|
454
|
+
"getting_slower": [{"job": j["job"], **j["slower"]} for j in result["jobs"] if j["slower"]],
|
|
455
|
+
"coverage": result.get("coverage"), "compared_with_similar_repos": result.get("benchmark"),
|
|
456
|
+
"pushes": result.get("pushes"), "this_week": result.get("changes")}
|
|
457
|
+
return _safe(run)()
|
|
458
|
+
|
|
459
|
+
@server.tool()
|
|
460
|
+
def is_known_flake(repo: str, name: str) -> dict:
|
|
461
|
+
"""Before fixing a failing test or job, check whether it is a known flake in this repo.
|
|
462
|
+
name: part of the test or job name (e.g. "test_upload" or "e2e")."""
|
|
463
|
+
def run():
|
|
464
|
+
result, source = result_for(repo, 30)
|
|
465
|
+
needle = name.lower()
|
|
466
|
+
matches = [(t, "test") for t in result.get("tests", []) if needle in t["id"].lower()]
|
|
467
|
+
matches += [(j, "job") for j in result["jobs"] if needle in j["job"].lower()]
|
|
468
|
+
matches.sort(key=lambda m: (m[0]["state"] not in KNOWN | {"broken"}, -m[0]["flake_events"]))
|
|
469
|
+
best = matches[0][0] if matches else None
|
|
470
|
+
if best and best["state"] in KNOWN and best["flake_events"] + (best["state"] == "fixed") > 0:
|
|
471
|
+
# A job or test is called known flaky only with enough flakes; fewer leave the change a likely cause.
|
|
472
|
+
verdict = "flaked before" if best["flake_events"] < SAFE_FLAKES else "known flaky"
|
|
473
|
+
elif best and best["state"] == "broken":
|
|
474
|
+
verdict = "already failing"
|
|
475
|
+
else:
|
|
476
|
+
verdict = "no history"
|
|
477
|
+
return {"repo": repo, "name": name, "verdict": verdict, "advice": ADVICE[verdict], "source": source,
|
|
478
|
+
"matches": [_brief(m, k) for m, k in matches[:5]]}
|
|
479
|
+
return _safe(run)()
|
|
480
|
+
|
|
481
|
+
@server.tool()
|
|
482
|
+
def ci_failure(run: str) -> dict:
|
|
483
|
+
"""Explain a failed CI run: each failure is called known flaky (it flaked before and its log shows a flake's sign:
|
|
484
|
+
check the error, then re-run it), maybe yours (it flaked before, but its error names a file the pull request
|
|
485
|
+
changed), flaked before (it has flaked, but rarely, in another step or with no flake's sign: check the change first), already failing on the default branch (the same error),
|
|
486
|
+
outside failure (a registry, network or runner), maybe flaky (it failed the way flaky checks do: re-run once to
|
|
487
|
+
tell), PR details (a check on the PR's title, description or labels: fix the PR, not the code), or new (likely
|
|
488
|
+
from the change). run: the run's URL."""
|
|
489
|
+
def go():
|
|
490
|
+
m = re.search(r"github\.com/([^/]+/[^/]+)/actions/runs/(\d+)", run)
|
|
491
|
+
if not m:
|
|
492
|
+
raise ValueError("Give the run's URL, like https://github.com/owner/name/actions/runs/123")
|
|
493
|
+
repo, run_id = m[1], int(m[2])
|
|
494
|
+
api = ci_api(ci_token or ci_cli.token(), repo, max_requests=300)
|
|
495
|
+
body = api._get(f"/repos/{repo}/actions/runs/{run_id}")
|
|
496
|
+
att = body.get("run_attempt") or 1
|
|
497
|
+
data = {"runs": [run_record(body, att)],
|
|
498
|
+
"jobs": [job_record(j, run_id, att) for j in api.jobs(run_id, att)],
|
|
499
|
+
"tests": _reports(api, body, {att: body.get("run_started_at")}, TEST_ARTIFACTS, 50_000_000, set())}
|
|
500
|
+
result, source = result_for(repo, 30)
|
|
501
|
+
branch = result.get("default_branch")
|
|
502
|
+
try:
|
|
503
|
+
branch = branch or (api._get(f"/repos/{repo}") or {}).get("default_branch")
|
|
504
|
+
mains = _default_branch_failures(api, repo, data, body, branch)
|
|
505
|
+
except APIError:
|
|
506
|
+
mains = 0 # without the default branch's record, "already failing" just can't be said
|
|
507
|
+
cache = {}
|
|
508
|
+
items = classify(api, data, {**result, "default_branch": branch}, run_id, cache)
|
|
509
|
+
logs = cache.get(run_id) or {}
|
|
510
|
+
for i in items:
|
|
511
|
+
# What an agent would otherwise open itself: the job, its error lines, and how often the verdict held.
|
|
512
|
+
if i.get("job_id"):
|
|
513
|
+
i["url"] = f"https://github.com/{repo}/actions/runs/{run_id}/job/{i['job_id']}"
|
|
514
|
+
i["errors"] = error_excerpt(logs.get(i["job_id"]))
|
|
515
|
+
i["right_so_far"] = TRACK_RECORD.get(i["verdict"])
|
|
516
|
+
checked = [f"this run's {sum(j['c'] in ('failure', 'timed_out') for j in data['jobs'])} failed jobs and their "
|
|
517
|
+
f"failing steps' logs ({len(logs)} read)",
|
|
518
|
+
f"the default branch ({branch}): {mains} failed pushes of this workflow in the 3 days before"
|
|
519
|
+
if branch and body.get("head_branch") != branch else "this run is on the default branch",
|
|
520
|
+
f"{result.get('window_days', 30)} days of this repo's CI history ({source})"]
|
|
521
|
+
return {"repo": repo, "run": run_id, "conclusion": body.get("conclusion"), "source": source,
|
|
522
|
+
"failures": items, "checked": checked, "summary": failure_summary(items)}
|
|
523
|
+
return _safe(go)()
|
|
524
|
+
|
|
525
|
+
@server.tool()
|
|
526
|
+
def propose_change(repo: str, settings: dict | None = None, command: str | None = None, note: str = "") -> dict:
|
|
527
|
+
"""A link to a prefilled GitHub issue that changes the repo's StackVitals settings and/or records a decision
|
|
528
|
+
(quarantine, mute, acknowledge, snooze, exclude, accept). Nothing changes until a person with access opens the link
|
|
529
|
+
and presses Submit; the StackVitals workflow then applies it and answers. Checked first with the Action's own
|
|
530
|
+
parsers. settings: {"alerts.pickup_p75_hours": 6, ...} (keys as in docs/settings.md); command: one line like
|
|
531
|
+
"/stackvitals mute test:<id> for 7d because <reason>"; note: context for the person. Give the person the url."""
|
|
532
|
+
def run():
|
|
533
|
+
from stackvitals.core.propose import ProposalError, proposal
|
|
534
|
+
try:
|
|
535
|
+
return {"repo": repo, **proposal(repo, settings, command, note)}
|
|
536
|
+
except ProposalError as exc:
|
|
537
|
+
return {"repo": repo, "error": str(exc)}
|
|
538
|
+
return _safe(run)()
|
|
539
|
+
|
|
540
|
+
@server.tool()
|
|
541
|
+
def insights(repo: str, days: int = 30) -> dict:
|
|
542
|
+
"""What to fix first, across modules, and the findings that join CI and pull requests. findings: each a sentence with
|
|
543
|
+
its number, n, evidence links and the action, ranked by hours of waiting or outage at stake (flaky jobs and tests by
|
|
544
|
+
the PR-hours they cost, reviews asked for and not started, incidents after deployments, dependency updates with
|
|
545
|
+
failing checks, the outside-contributor review gap). red_ci: how long merged PRs sat ready for review with a
|
|
546
|
+
failing check on their newest commit, each wait traced to the run that started it and what ended it, with counts
|
|
547
|
+
and ranges. repo: owner/name; days: history to read (max 90)."""
|
|
548
|
+
def run():
|
|
549
|
+
tok, home = ci_token or ci_cli.token(), Path(ci_home or ci_cli.home())
|
|
550
|
+
found, _ = services.findings(repo, max(1, min(days, 90)), tok, home, make_api=ci_api, make_gql=flow_gql)
|
|
551
|
+
out, source = services.red_ci(repo, max(1, min(days, 90)), tok, home, make_api=ci_api, make_gql=flow_gql)
|
|
552
|
+
return {"repo": repo, "source": source, "findings": found, "red_ci": out}
|
|
553
|
+
return _safe(run)()
|
|
554
|
+
|
|
555
|
+
def delivery_for(repo: str) -> tuple[dict, str]:
|
|
556
|
+
return services.delivery(repo, 90, ci_token or ci_cli.token(), Path(ci_home or ci_cli.home()), make_api=ci_api,
|
|
557
|
+
make_gql=flow_gql)
|
|
558
|
+
|
|
559
|
+
@server.tool()
|
|
560
|
+
def delivery_health(repo: str) -> dict:
|
|
561
|
+
"""A repo's delivery: DORA's keys with DevLake's counting and tiers (deployment frequency, lead time for changes,
|
|
562
|
+
change failure rate, recovery) plus deployment rework rate, where deployments came from, cycle time P50/P75, sizes,
|
|
563
|
+
stuck PRs, coding agents' PRs and people's AI-marked PRs each compared like with like (the same author's other PRs,
|
|
564
|
+
or people's PRs of the same month and size) on reverts, CI failures, failed deploys and merges, with ranges,
|
|
565
|
+
matched shares and the gap each could detect,
|
|
566
|
+
merge-queue health, where the work went by month (feature, fix, maintenance, security, docs, with how each PR was
|
|
567
|
+
sorted), and with Jira or Linear connected, issue lead and cycle time by type and the last sprints. Each DORA key
|
|
568
|
+
carries its likely range and, when the range crosses a tier line, both tiers; `this_week` checks the latest week
|
|
569
|
+
against the four before and flags only changes beyond noise."""
|
|
570
|
+
def run():
|
|
571
|
+
out, source = delivery_for(repo)
|
|
572
|
+
keep = ("window_days", "prs", "deploy_source", "deploy_confidence", "deploy_names", "deployments_unreadable", "dora",
|
|
573
|
+
"sizes", "merge_frequency", "review_depth", "merged_without_review", "outliers", "ai", "tracker",
|
|
574
|
+
"merge_queue", "investment")
|
|
575
|
+
return {"repo": repo, "source": source, **{k: out.get(k) for k in keep},
|
|
576
|
+
"cycle_time": {k: out["phases"][k] for k in ("cycle", "cycle_to_merge")},
|
|
577
|
+
"stuck": {k: len(v) for k, v in out["stuck"].items()}, "this_week": out.get("changes"),
|
|
578
|
+
"generated": out.get("generated")}
|
|
579
|
+
return _safe(run)()
|
|
580
|
+
|
|
581
|
+
@server.tool()
|
|
582
|
+
def cycle_time(repo: str) -> dict:
|
|
583
|
+
"""Pull request cycle time in LinearB's four phases (coding, pickup, review, deploy, with review split into approve and merge) with P50, P75,
|
|
584
|
+
a range for the median, sample sizes, LinearB's band for each P75 and the outliers left out."""
|
|
585
|
+
def run():
|
|
586
|
+
out, source = delivery_for(repo)
|
|
587
|
+
return {"repo": repo, "source": source, "phases": out["phases"], "outliers": out["outliers"],
|
|
588
|
+
"window_days": out["window_days"], "generated": out.get("generated")}
|
|
589
|
+
return _safe(run)()
|
|
590
|
+
|
|
591
|
+
@server.tool()
|
|
592
|
+
def stuck_prs(repo: str) -> dict:
|
|
593
|
+
"""Open pull requests that are stuck, by LinearB's published cut-offs: review asked 3+ days ago with no review,
|
|
594
|
+
ready for 30+ days, 10+ comments or reviews; and ours: failing checks nobody acted on for a day."""
|
|
595
|
+
def run():
|
|
596
|
+
out, source = delivery_for(repo)
|
|
597
|
+
return {"repo": repo, "source": source, "stuck": out["stuck"], "generated": out.get("generated")}
|
|
598
|
+
return _safe(run)()
|
|
599
|
+
|
|
600
|
+
@server.tool()
|
|
601
|
+
def reviewer_load(repo: str) -> dict:
|
|
602
|
+
"""Who holds the open pull requests: each reviewer's open review requests, the PRs waiting on them with the
|
|
603
|
+
reason, and (asked on demand with your own token) how fast each one usually answers."""
|
|
604
|
+
def run():
|
|
605
|
+
result, source = flow_for(repo)
|
|
606
|
+
holding = {}
|
|
607
|
+
for q in result.get("queue") or []:
|
|
608
|
+
if q.get("actor") == "reviewers":
|
|
609
|
+
for who in q.get("who") or []:
|
|
610
|
+
holding.setdefault(who, []).append({"pr": q["n"], "why": q.get("why"), "since": q.get("since")})
|
|
611
|
+
speed = {r["reviewer"]: {"median_hours": _hours(r["median_s"]), "n": r["n"]} for r in result.get("responders") or []}
|
|
612
|
+
return {"repo": repo, "source": source,
|
|
613
|
+
"reviewers": [{"reviewer": r["reviewer"], "open_requests": r["open_requests"], "oldest_since": r["oldest_since"],
|
|
614
|
+
"holding": holding.get(r["reviewer"], []), "answers": speed.get(r["reviewer"])}
|
|
615
|
+
for r in result.get("load") or []]}
|
|
616
|
+
return _safe(run)()
|
|
617
|
+
|
|
618
|
+
@server.tool()
|
|
619
|
+
def test_status(repo: str, test: str) -> dict:
|
|
620
|
+
"""A test's status and why: flaky, keeps failing, one flake seen or fixed; the monitors holding it there (pass on
|
|
621
|
+
retry, failure rate, keeps failing), labels (slow, timeout inflation), since when, any mute, and the failure group
|
|
622
|
+
it belongs to (other tests failing the same way). test: part of the test's name."""
|
|
623
|
+
def run():
|
|
624
|
+
result, source = result_for(repo, 30)
|
|
625
|
+
groups = {g["id"]: g for g in result.get("failure_groups") or []}
|
|
626
|
+
needle = test.lower()
|
|
627
|
+
hits = [t for t in result.get("tests", []) if needle in t["id"].lower()][:10]
|
|
628
|
+
keep = ("id", "state", "monitors", "labels", "since", "failure_rate", "muted_until", "flake_events", "runs",
|
|
629
|
+
"failures", "message", "last_flake", "owners")
|
|
630
|
+
return {"repo": repo, "source": source,
|
|
631
|
+
"matches": [{**{k: t.get(k) for k in keep if k in t},
|
|
632
|
+
"group": groups.get(t.get("group"))} for t in hits]}
|
|
633
|
+
return _safe(run)()
|
|
634
|
+
|
|
635
|
+
@server.tool()
|
|
636
|
+
def failure_group(repo: str, group: str = "") -> dict:
|
|
637
|
+
"""Failures with one cause across tests and runs (same exception at the same place in the code), biggest first,
|
|
638
|
+
with links to example runs and an investigation prompt (what failed, where, how often, the runs, what to find out);
|
|
639
|
+
group: an id (or its start) from test_status or ci_failure, empty for all."""
|
|
640
|
+
def run():
|
|
641
|
+
from stackvitals.ci.investigate import prompt
|
|
642
|
+
result, source = result_for(repo, 30)
|
|
643
|
+
found = [g for g in result.get("failure_groups") or [] if g["id"].startswith(group)]
|
|
644
|
+
for g in found:
|
|
645
|
+
g["investigation_prompt"] = prompt(repo, g, result.get("tests") or [])
|
|
646
|
+
g.pop("prompt", None)
|
|
647
|
+
g["examples"] = [{**x, "url": f"https://github.com/{repo}/actions/runs/{x['run']}/attempts/{x['att']}"}
|
|
648
|
+
for x in g.get("examples") or [] if int(x.get("run") or 0) > 0]
|
|
649
|
+
return {"repo": repo, "source": source, "groups": found[:20]}
|
|
650
|
+
return _safe(run)()
|
|
651
|
+
|
|
652
|
+
@server.tool()
|
|
653
|
+
def ci_slowest(repo: str) -> dict:
|
|
654
|
+
"""The slowest jobs and tests in a repo's CI with daily median times, the ones that got slower in the last 7 days,
|
|
655
|
+
tests added in the last 2 weeks (and whether they already fail or flake) and tests skipped now, since when."""
|
|
656
|
+
def run():
|
|
657
|
+
result, source = result_for(repo, 14)
|
|
658
|
+
jobs = sorted((j for j in result["jobs"] if j["median_s"]), key=lambda j: -j["median_s"])[:10]
|
|
659
|
+
return {"repo": repo, "source": source,
|
|
660
|
+
"slowest_jobs": [{"job": j["job"], "median_s": j["median_s"], "p95_s": j["p95_s"],
|
|
661
|
+
"slower": j["slower"], "trend": j.get("trend")} for j in jobs],
|
|
662
|
+
"slowest_tests": (result.get("slow_tests") or [])[:10],
|
|
663
|
+
"new_tests": (result.get("new_tests") or [])[:20], "skipped_tests": (result.get("skipped_tests") or [])[:20]}
|
|
664
|
+
return _safe(run)()
|
|
665
|
+
|
|
666
|
+
if tools == "core":
|
|
667
|
+
# Fewer tools keep an agent's context small; the rest stay one flag away.
|
|
668
|
+
for name in [x.name for x in server._tool_manager.list_tools() if x.name not in CORE_TOOLS]:
|
|
669
|
+
server.remove_tool(name)
|
|
670
|
+
return server
|