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,120 @@
|
|
|
1
|
+
"""The agent work ledger: each coding agent's pull requests on their own against people's of the same month and size;
|
|
2
|
+
the review load agents add (what arrives against what merges, the queue waiting for a first review, reviewers' open
|
|
3
|
+
requests); and the review an open pull request can expect, from similar ones merged here."""
|
|
4
|
+
from collections import Counter, defaultdict
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from stackvitals.core.ai_outcomes import MIN_N, _band, assisted_by, mode, outcomes
|
|
8
|
+
from stackvitals.core.stats import median, percentile
|
|
9
|
+
|
|
10
|
+
SIMILAR_MIN = 5 # fewer similar merged PRs and an open one gets no expectation
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def agent_of(p: dict) -> str | None:
|
|
14
|
+
"""The coding agent behind an agent's PR ("copilot agent", "devin agent", ...), else None."""
|
|
15
|
+
if mode(p) != "agent":
|
|
16
|
+
return None
|
|
17
|
+
return next((t for t in assisted_by(p) if t.endswith(" agent")), "coding agent")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _rounds(p: dict) -> int:
|
|
21
|
+
return sum(1 for e in p.get("ev") or [] if e and e[0] == "review")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def by_agent(rows: list, ci: dict, incidents: list[dict], now: datetime, decided: list | None = None) -> dict:
|
|
25
|
+
"""{agent: {"prs", "merge_rate", "review_rounds_p50", "window", "matched"}}: each agent's merged PRs measured as the
|
|
26
|
+
AI outcomes measure all agents together, compared with people's PRs of the same month and size."""
|
|
27
|
+
names = sorted({a for r in rows if (a := agent_of(r[0]))} | {a for p in decided or [] if (a := agent_of(p))})
|
|
28
|
+
out = {}
|
|
29
|
+
for name in names:
|
|
30
|
+
# Only this agent on the agent side; people's PRs stay the comparison.
|
|
31
|
+
keep = [r for r in rows if mode(r[0]) != "agent" or agent_of(r[0]) == name]
|
|
32
|
+
mine = [p for p in decided or [] if agent_of(p) == name]
|
|
33
|
+
got = outcomes(keep, ci, incidents, now, decided=[p for p in decided or [] if mode(p) != "agent"] + mine
|
|
34
|
+
if decided is not None else None)
|
|
35
|
+
merged = [r[0] for r in keep if agent_of(r[0]) == name]
|
|
36
|
+
rounds = [_rounds(p) for p in merged]
|
|
37
|
+
closed = mine # `decided` holds merged and closed PRs only
|
|
38
|
+
out[name] = {"prs": len(merged), "decided": len(closed),
|
|
39
|
+
"merge_rate": round(sum(1 for p in closed if p.get("mg")) / len(closed), 4)
|
|
40
|
+
if len(closed) >= MIN_N else None,
|
|
41
|
+
"review_rounds_p50": median(rounds) if len(rounds) >= MIN_N else None,
|
|
42
|
+
"window": got["window"]["agent"], "matched": got["matched"].get("agent")}
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _kind(p: dict) -> str:
|
|
47
|
+
return "agent" if mode(p) == "agent" else "person"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def review_load(flow: dict, now: datetime, weeks: int = 8) -> dict:
|
|
51
|
+
"""Each of the last `weeks` weeks: PRs opened and merged, agents' and people's; the open PRs waiting for a first
|
|
52
|
+
review now (drafts left out); and how the open review requests spread over reviewers."""
|
|
53
|
+
prs = (flow.get("closed") or []) + (flow.get("open") or [])
|
|
54
|
+
rows = []
|
|
55
|
+
for k in range(weeks - 1, -1, -1):
|
|
56
|
+
end = now - timedelta(days=7 * k)
|
|
57
|
+
start, e = (end - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ"), end.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
58
|
+
opened, merged = Counter(), Counter()
|
|
59
|
+
for p in prs:
|
|
60
|
+
if start <= (p.get("cr") or "") < e:
|
|
61
|
+
opened[_kind(p)] += 1
|
|
62
|
+
if start <= (p.get("mg") or "") < e:
|
|
63
|
+
merged[_kind(p)] += 1
|
|
64
|
+
rows.append({"week_end": e[:10], "opened": {k: opened[k] for k in ("agent", "person")},
|
|
65
|
+
"merged": {k: merged[k] for k in ("agent", "person")}})
|
|
66
|
+
live = [p for p in flow.get("open") or [] if not p.get("draft")]
|
|
67
|
+
waiting = Counter(_kind(p) for p in live if not any(e and e[0] == "review" for e in p.get("ev") or []))
|
|
68
|
+
load, from_agents = Counter(), 0
|
|
69
|
+
for p in live:
|
|
70
|
+
for who in p.get("rq") or []:
|
|
71
|
+
load[who] += 1
|
|
72
|
+
from_agents += _kind(p) == "agent"
|
|
73
|
+
total = sum(load.values())
|
|
74
|
+
# Counts only: who holds which request is on the Pull Requests page.
|
|
75
|
+
return {"weeks": rows, "waiting": {k: waiting[k] for k in ("agent", "person")},
|
|
76
|
+
"reviewers": {"n": len(load), "max_open": max(load.values(), default=0),
|
|
77
|
+
"agent_share": round(from_agents / total, 4) if total else None}}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def expected_review(open_prs: list[dict], history: list) -> list[dict]:
|
|
81
|
+
"""For each open PR: the review hours (P50, P75) of merged PRs here of the same kind and size band, or the bands
|
|
82
|
+
next to it when its own has too few; None when nothing similar has merged yet."""
|
|
83
|
+
pool = defaultdict(list)
|
|
84
|
+
for p, ph, _d in history:
|
|
85
|
+
if ph.get("review") is not None:
|
|
86
|
+
pool[(_kind(p), _band(p))].append(ph["review"])
|
|
87
|
+
out = []
|
|
88
|
+
for p in open_prs:
|
|
89
|
+
kind, band = _kind(p), _band(p)
|
|
90
|
+
hours = pool[(kind, band)]
|
|
91
|
+
if len(hours) < SIMILAR_MIN:
|
|
92
|
+
hours = pool[(kind, band - 1)] + hours + pool[(kind, band + 1)]
|
|
93
|
+
enough = len(hours) >= SIMILAR_MIN
|
|
94
|
+
out.append({"pr": p.get("n"), "kind": kind, "similar": len(hours) if enough else 0,
|
|
95
|
+
"p50_h": round(median(hours), 2) if enough else None,
|
|
96
|
+
"p75_h": round(percentile(hours, 0.75), 2) if enough else None})
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def leaderboard(data) -> dict | None:
|
|
101
|
+
"""The crawl's agent leaderboard as a page may show it: short lowercase agent names and numbers in range only
|
|
102
|
+
(a damaged or tampered file shows nothing rather than something wrong)."""
|
|
103
|
+
import re
|
|
104
|
+
if not isinstance(data, dict) or not isinstance(data.get("agents"), dict):
|
|
105
|
+
return None
|
|
106
|
+
share = lambda x: isinstance(x, (int, float)) and not isinstance(x, bool) and 0 <= x <= 1 # noqa: E731
|
|
107
|
+
count = lambda x: isinstance(x, int) and not isinstance(x, bool) and x >= 0 # noqa: E731
|
|
108
|
+
gap = lambda x: isinstance(x, (int, float)) and not isinstance(x, bool) and -1 <= x <= 1 # noqa: E731
|
|
109
|
+
agents = {}
|
|
110
|
+
for name, a in data["agents"].items():
|
|
111
|
+
if not (isinstance(name, str) and re.fullmatch(r"[a-z][a-z ]{1,20}", name) and isinstance(a, dict)
|
|
112
|
+
and count(a.get("repos")) and count(a.get("prs")) and share(a.get("merged")) and share(a.get("merged_by_others"))):
|
|
113
|
+
continue
|
|
114
|
+
entry = {k: a[k] for k in ("repos", "prs", "merged", "merged_by_others")}
|
|
115
|
+
vs = a.get("vs_people")
|
|
116
|
+
if isinstance(vs, dict) and all(gap(vs.get(k)) for k in ("gap", "low", "high")) and count(vs.get("prs_compared")):
|
|
117
|
+
entry["vs_people"] = {k: vs[k] for k in ("gap", "low", "high", "prs_compared")}
|
|
118
|
+
agents[name] = entry
|
|
119
|
+
days = data.get("window_days")
|
|
120
|
+
return {"window_days": days if count(days) else None, "agents": agents} if agents else None
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Working agreements (Swarmia's team rules), set under [agreements] and off until a team sets one: the pull requests of
|
|
2
|
+
the last 14 days that broke one (open ones still waiting count too), counts per rule, and 13 weekly windows for the app's
|
|
3
|
+
"how often would this have been broken" preview. Measured with Delivery's phases, so the numbers match its page."""
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from stackvitals.core.time import iso
|
|
7
|
+
|
|
8
|
+
WINDOW_DAYS = 14
|
|
9
|
+
WEEKS = 13
|
|
10
|
+
LABELS = {"review_within_hours": "First review within {} h", "merge_within_days": "Merged within {} days",
|
|
11
|
+
"max_pr_lines": "PRs under {} lines", "linked_issue": "PRs link an issue", "review_before_merge": "Reviewed before merge"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _hours(a: str, b: datetime) -> float:
|
|
15
|
+
return (b - datetime.fromisoformat(a.replace("Z", "+00:00"))).total_seconds() / 3600
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _merged_break(rule: str, limit, p: dict, ph: dict):
|
|
19
|
+
"""(broken, the value that broke it) for one merged pull request."""
|
|
20
|
+
if rule == "review_within_hours":
|
|
21
|
+
return (ph.get("pickup") or 0) > limit, round(ph.get("pickup") or 0, 1)
|
|
22
|
+
if rule == "merge_within_days":
|
|
23
|
+
days = (ph.get("cycle_to_merge") or 0) / 24
|
|
24
|
+
return days > limit, round(days, 1)
|
|
25
|
+
if rule == "max_pr_lines":
|
|
26
|
+
lines = (p.get("add") or 0) + (p.get("del") or 0)
|
|
27
|
+
return lines > limit, lines
|
|
28
|
+
if rule == "linked_issue":
|
|
29
|
+
return not p.get("iss"), False
|
|
30
|
+
if rule == "review_before_merge":
|
|
31
|
+
return not ph.get("reviewed"), False
|
|
32
|
+
return False, None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _open_break(rule: str, limit, p: dict, now: datetime):
|
|
36
|
+
"""Open pull requests already past a time limit (the rest are judged when they merge)."""
|
|
37
|
+
from stackvitals.flow.analyze import ready_at
|
|
38
|
+
ready = ready_at(p) or p.get("cr")
|
|
39
|
+
if not ready or p.get("draft"):
|
|
40
|
+
return False, None
|
|
41
|
+
waited = _hours(ready, now)
|
|
42
|
+
if rule == "review_within_hours":
|
|
43
|
+
# Delivery's pickup: a comment or review by another person; bots and the author's own words don't count.
|
|
44
|
+
from stackvitals.core.delivery import phases
|
|
45
|
+
return phases(p, None).get("first_activity") is None and waited > limit, round(waited, 1)
|
|
46
|
+
if rule == "merge_within_days":
|
|
47
|
+
return waited / 24 > limit, round(waited / 24, 1)
|
|
48
|
+
return False, None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check(rows: list[tuple], open_prs: list[dict], cfg: dict, now: datetime) -> dict | None:
|
|
52
|
+
"""rows: Delivery's merged people's pull requests with their phases; None when no agreement is set."""
|
|
53
|
+
rules = {k: v for k, v in (cfg or {}).items() if k in LABELS and v}
|
|
54
|
+
if not rules:
|
|
55
|
+
return None
|
|
56
|
+
since = iso(now - timedelta(days=WINDOW_DAYS))
|
|
57
|
+
recent = [(p, ph) for p, ph, _dep in rows if (p.get("mg") or "") >= since]
|
|
58
|
+
exceptions = []
|
|
59
|
+
for p, ph in recent:
|
|
60
|
+
for rule, limit in rules.items():
|
|
61
|
+
broken, value = _merged_break(rule, limit, p, ph)
|
|
62
|
+
if broken:
|
|
63
|
+
exceptions.append({"pr": p["n"], "rule": rule, "value": value, "limit": limit, "open": False, "at": p["mg"]})
|
|
64
|
+
for p in open_prs or []:
|
|
65
|
+
if (p.get("at") or "User") != "User":
|
|
66
|
+
continue
|
|
67
|
+
for rule, limit in rules.items():
|
|
68
|
+
broken, value = _open_break(rule, limit, p, now)
|
|
69
|
+
if broken:
|
|
70
|
+
exceptions.append({"pr": p["n"], "rule": rule, "value": value, "limit": limit, "open": True, "at": iso(now)})
|
|
71
|
+
weekly = {rule: [] for rule in rules}
|
|
72
|
+
for k in range(WEEKS - 1, -1, -1):
|
|
73
|
+
hi, lo = iso(now - timedelta(days=7 * k)), iso(now - timedelta(days=7 * (k + 1)))
|
|
74
|
+
week = [(p, ph) for p, ph, _dep in rows if lo < (p.get("mg") or "") <= hi]
|
|
75
|
+
for rule, limit in rules.items():
|
|
76
|
+
weekly[rule].append([hi[:10], sum(1 for p, ph in week if _merged_break(rule, limit, p, ph)[0]), len(week)])
|
|
77
|
+
return {"rules": rules, "labels": {r: LABELS[r].format(v) for r, v in rules.items()}, "window_days": WINDOW_DAYS,
|
|
78
|
+
"merged": len(recent), "counts": {r: sum(1 for e in exceptions if e["rule"] == r) for r in rules},
|
|
79
|
+
"exceptions": sorted(exceptions, key=lambda e: (e["rule"], -e["pr"])), "weekly": weekly}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def nudges(agreed: dict | None, sent: list[str]) -> tuple[list[dict], list[str]]:
|
|
83
|
+
"""Open pull requests breaking an agreement that weren't announced yet, and the ones to remember (still open)."""
|
|
84
|
+
if not agreed:
|
|
85
|
+
return [], []
|
|
86
|
+
now_open = [e for e in agreed.get("exceptions") or [] if e.get("open")]
|
|
87
|
+
keys = [f"{e['pr']}|{e['rule']}" for e in now_open]
|
|
88
|
+
fresh = [e for e, k in zip(now_open, keys) if k not in set(sent)]
|
|
89
|
+
return fresh, keys
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def nudge_lines(fresh: list[dict], labels: dict, repo: str) -> list[str]:
|
|
93
|
+
unit = {"review_within_hours": "h", "merge_within_days": "days"}
|
|
94
|
+
return [f"[#{e['pr']}](https://github.com/{repo}/pull/{e['pr']}): {e['value']} {unit.get(e['rule'], '')} so far, "
|
|
95
|
+
f"against \"{labels.get(e['rule'], e['rule'])}\"" for e in fresh]
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""What AI-assisted work does to a repo, month by month: coding agents' PRs, people's PRs with an AI marker and the rest
|
|
2
|
+
side by side on review wait, size, CI failures, reverts and failed deployments; each AI kind compared like with like
|
|
3
|
+
(the same authors' other PRs, or people's PRs of the same month and size) with the gap it could detect; and DORA's
|
|
4
|
+
fifth metric, deployment rework rate."""
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter, defaultdict
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from stackvitals.core.actors import kind_of
|
|
11
|
+
from stackvitals.core.joins import runs_by_pr
|
|
12
|
+
from stackvitals.core.stats import mh_risk_difference, wilson
|
|
13
|
+
from stackvitals.core.time import iso
|
|
14
|
+
|
|
15
|
+
# Labels coding tools put on the PRs they helped with (Claude Code adds claude-code-assisted).
|
|
16
|
+
AI_LABELS = {"claude-code-assisted": "claude", "ai-assisted": "ai", "ai-generated": "ai", "copilot": "copilot", "cursor": "cursor",
|
|
17
|
+
"codex": "codex", "devin": "devin"}
|
|
18
|
+
AGENTS = ("copilot", "devin", "claude", "cursor", "codex", "openhands", "sweep")
|
|
19
|
+
MONTHS = 6
|
|
20
|
+
BATCH_MAX = 5
|
|
21
|
+
MIN_N = 10 # fewer PRs on a side and its numbers are left out (its count stays)
|
|
22
|
+
# The branches cloud agents open PRs from (AIDev's public rule), whoever's account opens them.
|
|
23
|
+
AGENT_BRANCH = re.compile(r"^(copilot|codex|cursor|devin|claude)[/-]", re.I)
|
|
24
|
+
MIN_MATCHED = 5 # fewer matched PRs and a comparison says nothing
|
|
25
|
+
SIDES = {"agent": "agent", "assisted": "assisted", "person": "others"}
|
|
26
|
+
KEYS = ("pickup_p75_h", "review_p75_h", "size_p75", "ci_n", "ci_failed_share", "flaky_share", "reverted_share",
|
|
27
|
+
"failed_deploy_share")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def assisted_by(p: dict) -> list[str]:
|
|
31
|
+
"""The tools that helped with a PR: commit trailers, labels, or a coding agent as its author."""
|
|
32
|
+
tools = set(p.get("ai") or [])
|
|
33
|
+
# "AI Generated" and "ai_generated" are the same label as ai-generated.
|
|
34
|
+
marks = {x.lower().replace(" ", "-").replace("_", "-") for x in p.get("lb") or []}
|
|
35
|
+
tools |= {AI_LABELS[x] for x in marks if x in AI_LABELS}
|
|
36
|
+
login = (p.get("au") or "").lower()
|
|
37
|
+
branch = AGENT_BRANCH.match(p.get("head") or "")
|
|
38
|
+
if branch:
|
|
39
|
+
tools.add(f"{branch.group(1).lower()} agent")
|
|
40
|
+
if kind_of(p.get("au"), p.get("at")) == "agent":
|
|
41
|
+
tools.add(f"{next((a for a in AGENTS if a in login), 'coding')} agent")
|
|
42
|
+
return sorted(tools)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def mode(p: dict) -> str:
|
|
46
|
+
"""'agent' (a coding agent opened it: its account, or an agent's branch such as codex/), 'assisted' (a person's PR
|
|
47
|
+
with an AI marker: a trailer or a label), or 'person'."""
|
|
48
|
+
if kind_of(p.get("au"), p.get("at")) == "agent" or AGENT_BRANCH.match(p.get("head") or ""):
|
|
49
|
+
return "agent"
|
|
50
|
+
return "assisted" if assisted_by(p) else "person"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _band(p: dict) -> int:
|
|
54
|
+
return int(math.log2((p.get("add") or 0) + (p.get("del") or 0) + 1))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _merger(p: dict) -> str | None:
|
|
58
|
+
return next((e[2] for e in p.get("ev") or [] if e and e[0] == "merged" and len(e) > 2), None)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def matched(treated: list, control: list, key, hit) -> dict:
|
|
62
|
+
"""A share compared within matched groups (Mantel-Haenszel with Sato's variance): the gap with its 95% range, the gap
|
|
63
|
+
it could detect (80% power), how many treated items found a match, and a plain verdict."""
|
|
64
|
+
groups = defaultdict(lambda: [0, 0, 0, 0])
|
|
65
|
+
for x in treated:
|
|
66
|
+
g = groups[key(x)]
|
|
67
|
+
g[0], g[1] = g[0] + hit(x), g[1] + 1
|
|
68
|
+
for x in control:
|
|
69
|
+
if (k := key(x)) in groups:
|
|
70
|
+
groups[k][2], groups[k][3] = groups[k][2] + hit(x), groups[k][3] + 1
|
|
71
|
+
used = [tuple(g) for g in groups.values() if g[1] and g[3]]
|
|
72
|
+
n = sum(g[1] for g in used)
|
|
73
|
+
out = {"matched": n, "of": len(treated), "matched_share": round(n / len(treated), 4) if treated else None}
|
|
74
|
+
if n < MIN_MATCHED:
|
|
75
|
+
return {**out, "gap": None, "verdict": "too few"}
|
|
76
|
+
rd, se = mh_risk_difference(used)
|
|
77
|
+
weight = sum(g[1] * g[3] / (g[1] + g[3]) for g in used)
|
|
78
|
+
total = sum(g[1] + g[3] for g in used)
|
|
79
|
+
# What it could detect is judged with no gap (a pooled share), so a rare outcome never claims a zero-point reach.
|
|
80
|
+
pooled = min(max(sum(g[0] + g[2] for g in used) / total, 1 / total), 1 - 1 / total)
|
|
81
|
+
null_se = math.sqrt(pooled * (1 - pooled) / weight)
|
|
82
|
+
# Sato's variance is zero when every pair goes the same way; the no-gap error keeps the range honest then.
|
|
83
|
+
se = se or null_se
|
|
84
|
+
low, high = max(-1.0, rd - 1.96 * se), min(1.0, rd + 1.96 * se)
|
|
85
|
+
verdict = "higher" if low > 0 else "lower" if high < 0 else "no measurable difference"
|
|
86
|
+
return {**out, "gap": round(rd, 4), "low": round(low, 4), "high": round(high, 4),
|
|
87
|
+
"detectable": round(2.8 * null_se, 4), "verdict": verdict}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Greedy to the last quote, and open at the end: 'Revert "X" because it broke CI' counts too.
|
|
91
|
+
REVERT_TITLE = re.compile(r'^Revert "(.*)"')
|
|
92
|
+
REVERT_NUMBER = re.compile(r"^Revert\s+#(\d+)\b", re.I)
|
|
93
|
+
SQUASH_SUFFIX = re.compile(r"\s*\(#(\d+)\)\s*$")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_revert(p: dict) -> bool:
|
|
97
|
+
title = p.get("ti") or ""
|
|
98
|
+
return bool(REVERT_TITLE.match(title) or REVERT_NUMBER.match(title))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def revert_target(p: dict, by_number: dict[int, dict]) -> int | None:
|
|
102
|
+
"""The PR a revert undid: the number in a squash-merge suffix ("Title (#12)") or "Revert #12", else the PR with that
|
|
103
|
+
title merged latest before the revert (so a re-landed PR with the same title is never marked)."""
|
|
104
|
+
title = p.get("ti") or ""
|
|
105
|
+
found = REVERT_NUMBER.match(title)
|
|
106
|
+
if found:
|
|
107
|
+
return int(found.group(1)) if int(found.group(1)) in by_number else None
|
|
108
|
+
found = REVERT_TITLE.match(title)
|
|
109
|
+
if not found:
|
|
110
|
+
return None
|
|
111
|
+
inner = found.group(1)
|
|
112
|
+
suffix = SQUASH_SUFFIX.search(inner)
|
|
113
|
+
if suffix and int(suffix.group(1)) in by_number:
|
|
114
|
+
return int(suffix.group(1))
|
|
115
|
+
bare = SQUASH_SUFFIX.sub("", inner)
|
|
116
|
+
earlier = [q for q in by_number.values() if q["n"] != p["n"] and q.get("mg") and q["mg"] <= (p.get("mg") or "~")
|
|
117
|
+
and SQUASH_SUFFIX.sub("", q.get("ti") or "") == bare]
|
|
118
|
+
return max(earlier, key=lambda q: q["mg"])["n"] if earlier else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _p75(values) -> float | None:
|
|
122
|
+
v = sorted(x for x in values if x is not None)
|
|
123
|
+
return round(v[min(len(v) - 1, int(0.75 * (len(v) - 1) + 0.5))], 2) if v else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _share(rows, hit) -> float | None:
|
|
127
|
+
return round(sum(1 for r in rows if hit(r)) / len(rows), 4) if rows else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _flaky(runs: list[dict]) -> bool:
|
|
131
|
+
"""A run that failed and then passed on a re-run of the same commit."""
|
|
132
|
+
by_id = defaultdict(list)
|
|
133
|
+
for r in runs:
|
|
134
|
+
by_id[r.get("id")].append(r)
|
|
135
|
+
for attempts in by_id.values():
|
|
136
|
+
attempts.sort(key=lambda r: r.get("att") or 1)
|
|
137
|
+
failed = next((i for i, r in enumerate(attempts) if r.get("c") in ("failure", "timed_out")), None)
|
|
138
|
+
if failed is not None and any(r.get("c") == "success" for r in attempts[failed + 1:]):
|
|
139
|
+
return True
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def outcomes(rows: list, ci: dict, incidents: list[dict], now: datetime, months: int = MONTHS, decided: list | None = None,
|
|
144
|
+
min_n: int = MIN_N) -> dict:
|
|
145
|
+
"""{"window": {"assisted", "others"}, "monthly": [{"month", "assisted", "others"}]} over merged PRs `rows` (pr, phases,
|
|
146
|
+
deployment); CI failures from runs that name the PR, reverts by title, failed deployments from attributed incidents;
|
|
147
|
+
merge rate over `decided` (merged and closed PRs) when given."""
|
|
148
|
+
runs = ci.get("runs") or []
|
|
149
|
+
# Fork PRs' runs don't name the PR: they are matched by head owner and branch. PRs merged before the first stored
|
|
150
|
+
# run are left out of the CI shares, since CI can say nothing about them.
|
|
151
|
+
by_pr = runs_by_pr(runs, [r[0] for r in rows])
|
|
152
|
+
ci_from = min((r["cr"] for r in runs if r.get("cr")), default=None)
|
|
153
|
+
failed_ci = {n for n, rs in by_pr.items() if any(r.get("c") in ("failure", "timed_out") for r in rs)}
|
|
154
|
+
flaky = {n for n, rs in by_pr.items() if _flaky(rs)}
|
|
155
|
+
# An incident can be pinned near a PR only in a small deployment; release-sized batches are left out.
|
|
156
|
+
batch = Counter(d["id"] for _p, _ph, d in rows if d)
|
|
157
|
+
by_number = {p["n"]: p for p, _ph, _d in rows}
|
|
158
|
+
reverted = {n for p, _ph, _d in rows if is_revert(p) and (n := revert_target(p, by_number)) is not None}
|
|
159
|
+
failed_deps = {i.get("deployment") for i in incidents if i.get("deployment")}
|
|
160
|
+
|
|
161
|
+
def stats(rs):
|
|
162
|
+
if len(rs) < min_n:
|
|
163
|
+
return {"n": len(rs), **{k: None for k in KEYS}}
|
|
164
|
+
seen = [r for r in rs if ci_from and (r[0].get("mg") or "") >= ci_from]
|
|
165
|
+
return {"n": len(rs), "pickup_p75_h": _p75(ph.get("pickup") for _p, ph, _d in rs),
|
|
166
|
+
"review_p75_h": _p75(ph.get("review") for _p, ph, _d in rs),
|
|
167
|
+
"size_p75": _p75((p.get("add") or 0) + (p.get("del") or 0) for p, _ph, _d in rs),
|
|
168
|
+
"ci_n": len(seen), "ci_failed_share": _share(seen, lambda r: r[0]["n"] in failed_ci),
|
|
169
|
+
"flaky_share": _share(seen, lambda r: r[0]["n"] in flaky),
|
|
170
|
+
"reverted_share": _share(rs, lambda r: r[0]["n"] in reverted),
|
|
171
|
+
"failed_deploy_share": _share([r for r in rs if r[2] and batch[r[2]["id"]] <= BATCH_MAX],
|
|
172
|
+
lambda r: r[2]["id"] in failed_deps)}
|
|
173
|
+
# Calendar months, this one included, and one start for every number (31-day steps landed a month early).
|
|
174
|
+
start = now.year * 12 + now.month - 1 - (months - 1)
|
|
175
|
+
first = f"{start // 12:04d}-{start % 12 + 1:02d}"
|
|
176
|
+
groups = defaultdict(lambda: {"agent": [], "assisted": [], "others": []})
|
|
177
|
+
window = {"agent": [], "assisted": [], "others": []}
|
|
178
|
+
for row in rows:
|
|
179
|
+
month = (row[0].get("mg") or "")[:7]
|
|
180
|
+
if month < first:
|
|
181
|
+
continue
|
|
182
|
+
side = SIDES[mode(row[0])]
|
|
183
|
+
window[side].append(row)
|
|
184
|
+
groups[month][side].append(row)
|
|
185
|
+
tools = defaultdict(int)
|
|
186
|
+
for p, _ph, _d in window["agent"] + window["assisted"]:
|
|
187
|
+
for t in assisted_by(p):
|
|
188
|
+
tools[t] += 1
|
|
189
|
+
rates = {"agent": [], "assisted": [], "others": []}
|
|
190
|
+
for p in decided or []:
|
|
191
|
+
if (p.get("mg") or p.get("st") == "CLOSED") and (p.get("mg") or p.get("cl") or "")[:7] >= first:
|
|
192
|
+
rates[SIDES[mode(p)]].append(p)
|
|
193
|
+
out = {side: stats(rs) for side, rs in window.items()}
|
|
194
|
+
if decided is not None:
|
|
195
|
+
for side, ps in rates.items():
|
|
196
|
+
out[side]["merge_rate"] = round(sum(1 for p in ps if p.get("mg")) / len(ps), 4) if ps and len(ps) >= min_n else None
|
|
197
|
+
out[side]["merge_rate_n"] = len(ps)
|
|
198
|
+
# Like with like: people's AI-marked PRs against the same authors' other PRs of the same size; agents' PRs against
|
|
199
|
+
# people's PRs of the same month and size (an agent has no "same author").
|
|
200
|
+
same_author = lambda r: ((r[0].get("au") or "").lower(), _band(r[0])) # noqa: E731
|
|
201
|
+
same_month = lambda r: ((r[0].get("mg") or r[0].get("cl") or "")[:7], _band(r[0])) # noqa: E731
|
|
202
|
+
seen = lambda rs: [r for r in rs if ci_from and (r[0].get("mg") or "") >= ci_from] # noqa: E731
|
|
203
|
+
small = lambda rs: [r for r in rs if r[2] and batch[r[2]["id"]] <= BATCH_MAX] # noqa: E731
|
|
204
|
+
checks = {"ci_failed": (seen, lambda r: r[0]["n"] in failed_ci), "reverted": (lambda rs: rs, lambda r: r[0]["n"] in reverted),
|
|
205
|
+
"failed_deploy": (small, lambda r: r[2]["id"] in failed_deps)}
|
|
206
|
+
pairs = {"assisted": same_author, "agent": same_month}
|
|
207
|
+
compared = {side: {name: matched(pick(window[side]), pick(window["others"]), key, hit) for name, (pick, hit) in checks.items()}
|
|
208
|
+
for side, key in pairs.items()}
|
|
209
|
+
if decided is not None:
|
|
210
|
+
# Merged at all, and merged by someone other than its author (most agents' PRs are merged by whoever ran them).
|
|
211
|
+
wrap = lambda ps: [(p, {}, None) for p in ps] # noqa: E731
|
|
212
|
+
by_others = lambda r: bool(r[0].get("mg")) and (_merger(r[0]) or "").lower() != (r[0].get("au") or "").lower() # noqa: E731
|
|
213
|
+
for side, key in pairs.items():
|
|
214
|
+
compared[side]["merged"] = matched(wrap(rates[side]), wrap(rates["others"]), key, lambda r: bool(r[0].get("mg")))
|
|
215
|
+
# A merged PR whose merger isn't known says nothing about who merged it: left out of this one.
|
|
216
|
+
known = lambda ps: [p for p in ps if not p.get("mg") or _merger(p)] # noqa: E731
|
|
217
|
+
compared[side]["merged_by_others"] = matched(wrap(known(rates[side])), wrap(known(rates["others"])), key, by_others)
|
|
218
|
+
people = window["assisted"] + window["others"]
|
|
219
|
+
authors = {(r[0].get("au") or "").lower() for r in people}
|
|
220
|
+
marking = {(r[0].get("au") or "").lower() for r in window["assisted"]}
|
|
221
|
+
coverage = {"people_prs": len(people), "marked": len(window["assisted"]),
|
|
222
|
+
"marked_share": round(len(window["assisted"]) / len(people), 4) if people else None,
|
|
223
|
+
"authors": len(authors), "authors_marking": len(marking)}
|
|
224
|
+
return {"window": out, "tools": dict(tools), "matched": compared, "coverage": coverage,
|
|
225
|
+
"monthly": [{"month": m, **{side: stats(g[side]) for side in ("agent", "assisted", "others")}}
|
|
226
|
+
for m, g in sorted(groups.items())]}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def baseline(bench: dict | None, language: str | None, stars: int | None) -> dict | None:
|
|
230
|
+
"""How often coding agents' PRs get merged in similar public repos (the crawl's median repo, last 6 months), with
|
|
231
|
+
people's outside PRs in the same repos for scale. A bad or missing file gives None."""
|
|
232
|
+
from mergescout.bench.ci import tier
|
|
233
|
+
from stackvitals.ci.bench import _label
|
|
234
|
+
groups = (bench or {}).get("agent_prs") or {}
|
|
235
|
+
size = tier(stars)
|
|
236
|
+
for key in (f"{language}|{size}", f"{language}|all", f"all|{size}", "all|all"):
|
|
237
|
+
g = groups.get(key)
|
|
238
|
+
if not isinstance(g, dict):
|
|
239
|
+
continue
|
|
240
|
+
a, o = (g.get("agent") or {}).get("merged"), (g.get("outsider") or {}).get("merged")
|
|
241
|
+
if isinstance(g.get("repos_with_agent_prs"), int) and all(isinstance(x, (int, float)) for x in (a, o)):
|
|
242
|
+
out = {"group": _label(key), "repos": g["repos_with_agent_prs"], "agent_merged": a, "outsider_merged": o}
|
|
243
|
+
m = ((bench or {}).get("agent_matched") or {}).get(key)
|
|
244
|
+
if isinstance(m, dict) and all(isinstance(m.get(k), dict) and isinstance(m[k].get("gap"), (int, float))
|
|
245
|
+
for k in ("merged", "merged_by_others")):
|
|
246
|
+
# The same group's like-for-like gap: agents' PRs against people's of the same repo, month and size.
|
|
247
|
+
out["matched"] = {k: m[k] for k in ("repos", "agent_prs", "merged", "merged_by_others") if k in m}
|
|
248
|
+
return out
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def rework_rate(deps: list[dict], rows: list, incidents: list[dict], start: datetime, end: datetime) -> dict:
|
|
253
|
+
"""Deployments in the range that shipped an unplanned fix for a production problem (a revert, a hotfix, or a PR whose
|
|
254
|
+
title names an incident issue), over all."""
|
|
255
|
+
s, e = iso(start), iso(end)
|
|
256
|
+
in_win = [d for d in deps if s <= d["finished"] <= e]
|
|
257
|
+
fixes = {i["number"] for i in incidents if i.get("source") in ("revert", "hotfix")}
|
|
258
|
+
issues = {i["number"] for i in incidents if i.get("source") == "issue"}
|
|
259
|
+
shipped, deployed = defaultdict(set), {}
|
|
260
|
+
for p, _ph, dep in rows:
|
|
261
|
+
if dep:
|
|
262
|
+
shipped[dep["id"]].add(p["n"])
|
|
263
|
+
deployed[p["n"]] = dep["id"]
|
|
264
|
+
# A PR whose title names an incident issue is a fix for it.
|
|
265
|
+
if {int(n) for n in re.findall(r"#(\d+)", p.get("ti") or "")} & issues:
|
|
266
|
+
fixes.add(p["n"])
|
|
267
|
+
by_number = {p["n"]: p for p, _ph, _d in rows}
|
|
268
|
+
for p, _ph, _d in rows:
|
|
269
|
+
# A revert that went out with the change it undid never fixed anything users saw.
|
|
270
|
+
target = revert_target(p, by_number) if p["n"] in fixes and is_revert(p) else None
|
|
271
|
+
if target is not None and deployed.get(target) is not None and deployed.get(target) == deployed.get(p["n"]):
|
|
272
|
+
fixes.discard(p["n"])
|
|
273
|
+
rework = [d for d in in_win if shipped[d["id"]] & fixes]
|
|
274
|
+
return {"rework_rate": round(len(rework) / len(in_win), 4) if in_win else None, "rework_deployments": len(rework),
|
|
275
|
+
"deployments_n": len(in_win),
|
|
276
|
+
"rework_range": [round(x, 4) for x in wilson(len(rework), len(in_win))] if in_win else None}
|