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,94 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
import duckdb
|
|
4
|
+
|
|
5
|
+
from mergescout.signals.identity import build_people, build_pr_class
|
|
6
|
+
from mergescout.signals.issues import build_issue_signals
|
|
7
|
+
from mergescout.signals.labels import build_label_trust
|
|
8
|
+
from mergescout.signals.repo_stats import build_repo_stats
|
|
9
|
+
from mergescout.signals.rules import RULES, Rules
|
|
10
|
+
from mergescout.signals.walls import build_walls
|
|
11
|
+
|
|
12
|
+
EVIDENCE_VERSION = 1
|
|
13
|
+
TABLES = ("people", "repo_stats", "issue_signals", "label_trust", "repo_walls", "candidates")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_candidates(con: duckdb.DuckDBPyConnection, now: datetime, rules: Rules = RULES) -> int:
|
|
17
|
+
# Built in one SQL pass: a Python loop took 285 s for 69k issues, far too slow for the full corpus.
|
|
18
|
+
con.execute("CREATE OR REPLACE MACRO iso(x) AS strftime(timezone('UTC', x), '%Y-%m-%dT%H:%M:%SZ')")
|
|
19
|
+
con.execute("""
|
|
20
|
+
CREATE OR REPLACE TABLE candidates AS
|
|
21
|
+
WITH lt AS (
|
|
22
|
+
SELECT repo_id, list({label: label, closed_n: closed_n, known_fixes: known_fixes,
|
|
23
|
+
outsider_fixes: outsider_fixes, outsider_share: outsider_share,
|
|
24
|
+
low: outsider_low, high: outsider_high, enough: coalesce(enough, false),
|
|
25
|
+
window_days: $window_days, median_fix_lines: median_fix_lines,
|
|
26
|
+
median_fix_files: median_fix_files} ORDER BY label) AS labels,
|
|
27
|
+
list('not enough evidence for label trust on ''' || label || ''' (' || known_fixes
|
|
28
|
+
|| ' fixes with a known author; need ' || $min_closed || ')' ORDER BY label)
|
|
29
|
+
FILTER (WHERE NOT coalesce(enough, false) AND closed_n > 0) AS label_notes
|
|
30
|
+
FROM label_trust GROUP BY repo_id),
|
|
31
|
+
wl AS (
|
|
32
|
+
SELECT repo_id, list({wall: wall, blocking: coalesce(blocking, false), evidence: evidence}
|
|
33
|
+
ORDER BY wall) AS walls
|
|
34
|
+
FROM repo_walls GROUP BY repo_id),
|
|
35
|
+
base AS (
|
|
36
|
+
SELECT s.*, r.name AS repo, coalesce(rs.merged, 0) AS om_merged,
|
|
37
|
+
coalesce(rs.merged, 0) + coalesce(rs.closed, 0) AS om_decided, rs.merge_rate, rs.merge_low,
|
|
38
|
+
rs.merge_high, coalesce(rs.merge_enough, false) AS om_enough,
|
|
39
|
+
coalesce(rs.newcomer_merged, 0) AS nm_merged, coalesce(rs.newcomer_decided, 0) AS nm_decided,
|
|
40
|
+
rs.newcomer_rate, rs.newcomer_low, rs.newcomer_high,
|
|
41
|
+
coalesce(rs.newcomer_enough, false) AS nm_enough, rs.window_days, rs.window_complete,
|
|
42
|
+
rs.median_review_hours, coalesce(rs.reviewed, 0) AS reviewed,
|
|
43
|
+
coalesce(rs.review_enough, false) AS review_enough, coalesce(rs.bot_prs, 0) AS bot_prs,
|
|
44
|
+
coalesce(rs.ai_prs, 0) AS ai_prs, coalesce(rs.insider_prs, 0) AS insider_prs,
|
|
45
|
+
coalesce(lt.labels, []) AS label_trust, coalesce(lt.label_notes, []) AS label_notes,
|
|
46
|
+
coalesce(wl.walls, []) AS walls
|
|
47
|
+
FROM issue_signals s JOIN repo_meta r USING (repo_id) LEFT JOIN repo_stats rs USING (repo_id)
|
|
48
|
+
LEFT JOIN lt USING (repo_id) LEFT JOIN wl USING (repo_id)
|
|
49
|
+
WHERE s.opportunity OR s.available)
|
|
50
|
+
SELECT repo_id, repo, number, title, coalesce(available, false) AS available,
|
|
51
|
+
coalesce(opportunity, false) AS opportunity,
|
|
52
|
+
to_json({
|
|
53
|
+
version: $version, computed_at: $computed_at,
|
|
54
|
+
source: {run_id: run_id, fetched_at: iso(fetched_at)},
|
|
55
|
+
issue: {labels: coalesce(labels, []), assignees: assignees, linked_open_prs: linked_open_prs,
|
|
56
|
+
dead_ends: dead_ends, interest_30d: interest_30d},
|
|
57
|
+
availability: {available: coalesce(available, false),
|
|
58
|
+
blocked_by: list_filter(string_split(coalesce(blocked_by, ''), ', '), lambda b: b <> ''),
|
|
59
|
+
last_claim_at: iso(last_claim_at), claimers: claimers},
|
|
60
|
+
repo: {
|
|
61
|
+
outsider_merge: {merged: om_merged, decided: om_decided, rate: merge_rate, low: merge_low,
|
|
62
|
+
high: merge_high, enough: om_enough, window_days: window_days,
|
|
63
|
+
window_complete: coalesce(window_complete, false)},
|
|
64
|
+
newcomer_merge: {merged: nm_merged, decided: nm_decided, rate: newcomer_rate, low: newcomer_low,
|
|
65
|
+
high: newcomer_high, enough: nm_enough, window_days: window_days,
|
|
66
|
+
window_complete: coalesce(window_complete, false)},
|
|
67
|
+
review_hours: {median: median_review_hours, n: reviewed, enough: review_enough},
|
|
68
|
+
excluded: {bot_prs: bot_prs, ai_prs: ai_prs, insider_prs: insider_prs}},
|
|
69
|
+
labels: label_trust, walls: walls,
|
|
70
|
+
notes: list_concat(list_filter([
|
|
71
|
+
CASE WHEN NOT om_enough THEN 'not enough evidence for outsider merge rate (' || om_decided
|
|
72
|
+
|| ' decided PRs; need ' || $min_decided || ')' END,
|
|
73
|
+
CASE WHEN NOT nm_enough THEN 'not enough evidence for newcomer merge rate (' || nm_decided
|
|
74
|
+
|| ' decided PRs; need ' || $min_decided || ')' END,
|
|
75
|
+
CASE WHEN window_complete = false THEN 'PR history covers only the last ' || window_days
|
|
76
|
+
|| ' days (newest ' || $pr_cap || ' PRs)' END,
|
|
77
|
+
CASE WHEN NOT review_enough THEN 'not enough evidence for review time (' || reviewed
|
|
78
|
+
|| ' maintainer reviews; need ' || $min_reviewed || ')' END], lambda n: n IS NOT NULL),
|
|
79
|
+
label_notes)})::VARCHAR AS evidence_json
|
|
80
|
+
FROM base""", {"version": EVIDENCE_VERSION, "computed_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "min_decided": rules.min_decided, "pr_cap": rules.pr_cap,
|
|
81
|
+
"min_reviewed": rules.min_reviewed, "min_closed": rules.min_closed,
|
|
82
|
+
"window_days": rules.window_days})
|
|
83
|
+
return con.execute("SELECT count(*) FROM candidates").fetchall()[0][0]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_all(con: duckdb.DuckDBPyConnection, now: datetime, rules: Rules = RULES) -> dict[str, int]:
|
|
87
|
+
build_people(con)
|
|
88
|
+
build_pr_class(con)
|
|
89
|
+
build_repo_stats(con, now, rules)
|
|
90
|
+
build_issue_signals(con, now, rules)
|
|
91
|
+
build_label_trust(con, now, rules)
|
|
92
|
+
build_walls(con)
|
|
93
|
+
build_candidates(con, now, rules)
|
|
94
|
+
return {t: con.execute(f"SELECT count(*) FROM {t}").fetchall()[0][0] for t in TABLES}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Exploit evidence for advisories' CVEs: CISA's Known Exploited Vulnerabilities list (exploited in the wild) and FIRST's
|
|
2
|
+
EPSS (the chance of exploitation in the next 30 days). Both are free and need no key; EPSS answers 100 CVEs a request."""
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from mergescout.signals.deps_map import http_get_json
|
|
6
|
+
|
|
7
|
+
KEV_FEED = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
|
|
8
|
+
EPSS_API = "https://api.first.org/data/v1/epss"
|
|
9
|
+
EPSS_BATCH = 100
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def kev_ids(get_json=http_get_json) -> set[str]:
|
|
13
|
+
"""The CVEs CISA lists as exploited in the wild; an unreadable feed gives none (never a guess)."""
|
|
14
|
+
try:
|
|
15
|
+
feed = get_json(KEV_FEED) or {}
|
|
16
|
+
except Exception: # noqa: BLE001
|
|
17
|
+
return set()
|
|
18
|
+
return {str(v.get("cveID")) for v in feed.get("vulnerabilities") or [] if str(v.get("cveID", "")).startswith("CVE-")}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def epss_scores(cves: list[str], get_json=http_get_json) -> dict[str, dict]:
|
|
22
|
+
"""{cve: {"epss", "percentile"}} for the CVEs EPSS scores; a batch that fails is left out, not guessed."""
|
|
23
|
+
out = {}
|
|
24
|
+
wanted = sorted({c for c in cves if c.startswith("CVE-")})
|
|
25
|
+
for n in range(0, len(wanted), EPSS_BATCH):
|
|
26
|
+
batch = wanted[n:n + EPSS_BATCH]
|
|
27
|
+
try:
|
|
28
|
+
got = get_json(f"{EPSS_API}?cve={quote(','.join(batch), safe=',')}&limit={EPSS_BATCH}") or {}
|
|
29
|
+
except Exception: # noqa: BLE001
|
|
30
|
+
continue
|
|
31
|
+
for row in got.get("data") or []:
|
|
32
|
+
try:
|
|
33
|
+
out[row["cve"]] = {"epss": round(float(row["epss"]), 5), "percentile": round(float(row["percentile"]), 5)}
|
|
34
|
+
except (KeyError, TypeError, ValueError):
|
|
35
|
+
continue
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def lookup(cves: list[str], get_json=http_get_json) -> dict[str, dict]:
|
|
40
|
+
"""{cve: {"epss", "percentile", "kev"}}: the shape osv.refresh_vulns(exploits=...) takes."""
|
|
41
|
+
kev = kev_ids(get_json)
|
|
42
|
+
scores = epss_scores(cves, get_json)
|
|
43
|
+
return {c: {**scores.get(c, {}), "kev": c in kev} for c in cves if c in scores or c in kev}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
import duckdb
|
|
4
|
+
|
|
5
|
+
from mergescout.bench.ci import _pct, quantiles, tier
|
|
6
|
+
from mergescout.bench.matched import mh_risk_difference
|
|
7
|
+
|
|
8
|
+
MIN_PRS = 10
|
|
9
|
+
MIN_GROUP = 50
|
|
10
|
+
METRICS = ("first_review_hours", "merge_hours", "outsider_merge_rate", "size_lines")
|
|
11
|
+
DEFINITION = ("per repo, PRs opened in the last 90 days by people (bots and AI agents left out): median hours from opening "
|
|
12
|
+
"to the first review by a maintainer other than the author; median hours from opening to merge; outside "
|
|
13
|
+
"contributors' merged share of their decided PRs; median lines added plus removed. Repos with 10+ PRs.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def flow_benchmarks(con: duckdb.DuckDBPyConnection, now: datetime, window_days: int = 90, top_languages: int = 25) -> dict:
|
|
17
|
+
"""Review-flow percentiles of public repos by language and size, from the crawl; groups need 50+ repos."""
|
|
18
|
+
rows = con.execute("""
|
|
19
|
+
WITH w AS (
|
|
20
|
+
SELECT p.*, coalesce(p.first_human_review_at, p.first_review_at) AS review_at,
|
|
21
|
+
coalesce(p.first_human_review_by, p.first_review_by) AS review_by
|
|
22
|
+
FROM pr_class p WHERE p.created_at >= $since AND p.author_class IN ('insider', 'outsider'))
|
|
23
|
+
SELECT w.repo_id, any_value(rm.language) AS language, any_value(rm.stars) AS stars, count(*) AS prs,
|
|
24
|
+
median(epoch(w.review_at - w.created_at) / 3600) FILTER (
|
|
25
|
+
WHERE w.review_at IS NOT NULL AND w.review_by IS DISTINCT FROM w.author_login AND rv.kind = 'insider')
|
|
26
|
+
AS first_review_hours,
|
|
27
|
+
median(epoch(w.merged_at - w.created_at) / 3600) FILTER (WHERE w.merged_at IS NOT NULL) AS merge_hours,
|
|
28
|
+
(count(*) FILTER (WHERE w.author_class = 'outsider' AND w.state = 'MERGED'))::DOUBLE
|
|
29
|
+
/ nullif(count(*) FILTER (WHERE w.author_class = 'outsider' AND w.state IN ('MERGED', 'CLOSED')), 0)
|
|
30
|
+
AS outsider_merge_rate,
|
|
31
|
+
median(coalesce(w.additions, 0) + coalesce(w.deletions, 0)) AS size_lines
|
|
32
|
+
FROM w LEFT JOIN people rv ON rv.repo_id = w.repo_id AND rv.login = w.review_by
|
|
33
|
+
LEFT JOIN repo_meta rm ON rm.repo_id = w.repo_id
|
|
34
|
+
GROUP BY w.repo_id HAVING count(*) >= $min_prs""",
|
|
35
|
+
{"since": now - timedelta(days=window_days), "min_prs": MIN_PRS}).fetchall()
|
|
36
|
+
cols = ("repo_id", "language", "stars", "prs", *METRICS)
|
|
37
|
+
repos = [dict(zip(cols, r)) for r in rows]
|
|
38
|
+
counts = {}
|
|
39
|
+
for r in repos:
|
|
40
|
+
counts[r["language"] or "Other"] = counts.get(r["language"] or "Other", 0) + 1
|
|
41
|
+
langs = {name for name, _ in sorted(counts.items(), key=lambda kv: -kv[1])[:top_languages]}
|
|
42
|
+
groups = {}
|
|
43
|
+
for r in repos:
|
|
44
|
+
lang = r["language"] if r["language"] in langs else "Other"
|
|
45
|
+
for key in (f"{lang}|{tier(r['stars'])}", f"{lang}|all", f"all|{tier(r['stars'])}", "all|all"):
|
|
46
|
+
groups.setdefault(key, []).append(r)
|
|
47
|
+
out = {}
|
|
48
|
+
for key, items in groups.items():
|
|
49
|
+
if len(items) < MIN_GROUP:
|
|
50
|
+
continue
|
|
51
|
+
entry = {"repos": len(items)}
|
|
52
|
+
for m in METRICS:
|
|
53
|
+
values = [round(float(r[m]), 4) for r in items if r[m] is not None]
|
|
54
|
+
if len(values) >= MIN_GROUP:
|
|
55
|
+
entry[m] = {"repos": len(values), **quantiles(values)}
|
|
56
|
+
out[key] = entry
|
|
57
|
+
return {"version": 1, "built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "window_days": window_days,
|
|
58
|
+
"repos": len(repos), "definition": DEFINITION, "population": 'public GitHub repos in the daily crawl with 10+ pull requests by people in the last 90 days', "gaps": 'no private or enterprise teams; GitHub only; review counted from opening, not from leaving draft', "groups": out}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _median(values: list[float]) -> float | None:
|
|
62
|
+
s, n = sorted(values), len(values)
|
|
63
|
+
return None if not n else (s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
BUCKETS = (("5-15", 5, 15), ("15-50", 15, 50), ("50+", 50, None))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def odds_backtest(con: duckdb.DuckDBPyConnection, now: datetime, horizon_days: int = 30, settle_days: int = 60,
|
|
70
|
+
history_days: int = 365, k: int = 10, min_past: int = 5, min_future: int = 3) -> dict:
|
|
71
|
+
"""How well a repo's past outside-PR merge rate predicts its next outside PRs: the raw rate, the rate pulled toward
|
|
72
|
+
the typical repo of its group by k PRs (what the suite shows), and that typical rate alone. The target is every
|
|
73
|
+
outside PR opened in the next horizon_days, merged within the settle window or not; errors weighted by PRs and
|
|
74
|
+
also given by how many past PRs a repo had."""
|
|
75
|
+
cut = now - timedelta(days=horizon_days + settle_days)
|
|
76
|
+
rows = con.execute("""
|
|
77
|
+
SELECT p.repo_id, any_value(rm.language), any_value(rm.stars),
|
|
78
|
+
count(*) FILTER (WHERE p.closed_at >= $lo AND p.closed_at < $cut AND p.state IN ('MERGED', 'CLOSED')),
|
|
79
|
+
count(*) FILTER (WHERE p.closed_at >= $lo AND p.closed_at < $cut AND p.state = 'MERGED'),
|
|
80
|
+
count(*) FILTER (WHERE p.created_at >= $cut AND p.created_at < $hi),
|
|
81
|
+
count(*) FILTER (WHERE p.created_at >= $cut AND p.created_at < $hi AND p.merged_at IS NOT NULL AND p.merged_at < $settled)
|
|
82
|
+
FROM pr_class p LEFT JOIN repo_meta rm USING (repo_id) WHERE p.author_class = 'outsider' GROUP BY p.repo_id""",
|
|
83
|
+
{"lo": cut - timedelta(days=history_days), "cut": cut, "hi": cut + timedelta(days=horizon_days),
|
|
84
|
+
"settled": cut + timedelta(days=horizon_days + settle_days)}).fetchall()
|
|
85
|
+
repos = [(lang or "Other", tier(stars), pc, pm, fn, fm) for _, lang, stars, pc, pm, fn, fm in rows
|
|
86
|
+
if pc >= min_past and fn >= min_future]
|
|
87
|
+
out = {"version": 2, "repos": len(repos), "prs": sum(r[4] for r in repos), "k": k,
|
|
88
|
+
"definition": f"outside PRs decided in the {history_days} days before a cut-off predict the share of outside PRs "
|
|
89
|
+
f"opened in the next {horizon_days} days that were merged within {horizon_days + settle_days} days "
|
|
90
|
+
f"of the cut-off; repos with {min_past}+ past and {min_future}+ next PRs; the typical rate is the "
|
|
91
|
+
f"median of its language and size group (50+ repos) or of all repos"}
|
|
92
|
+
if not repos:
|
|
93
|
+
return out
|
|
94
|
+
by_group = {}
|
|
95
|
+
for lang, size, pc, pm, _, _ in repos:
|
|
96
|
+
for key in (f"{lang}|{size}", "all|all"):
|
|
97
|
+
by_group.setdefault(key, []).append(pm / pc)
|
|
98
|
+
typical = {key: _median(v) for key, v in by_group.items() if len(v) >= 50 or key == "all|all"}
|
|
99
|
+
err, bins, buckets = {"raw": 0.0, "shrunk": 0.0, "typical_only": 0.0}, {}, {}
|
|
100
|
+
for lang, size, pc, pm, fn, fm in repos:
|
|
101
|
+
typ = typical.get(f"{lang}|{size}", typical["all|all"])
|
|
102
|
+
observed, shrunk = fm / fn, (pm + k * typ) / (pc + k)
|
|
103
|
+
name = next(label for label, lo, hi in BUCKETS if pc >= lo and (hi is None or pc < hi))
|
|
104
|
+
b = buckets.setdefault(name, {"repos": 0, "prs": 0, "err": {"raw": 0.0, "shrunk": 0.0, "typical_only": 0.0}})
|
|
105
|
+
b["repos"] += 1
|
|
106
|
+
b["prs"] += fn
|
|
107
|
+
for key, pred in (("raw", pm / pc), ("shrunk", shrunk), ("typical_only", typ)):
|
|
108
|
+
err[key] += abs(pred - observed) * fn
|
|
109
|
+
b["err"][key] += abs(pred - observed) * fn
|
|
110
|
+
cal = bins.setdefault(min(int(shrunk * 10), 9), [0, 0.0, 0])
|
|
111
|
+
cal[0] += fn
|
|
112
|
+
cal[1] += shrunk * fn
|
|
113
|
+
cal[2] += fm
|
|
114
|
+
total = out["prs"]
|
|
115
|
+
|
|
116
|
+
def table(items):
|
|
117
|
+
# The share of next PRs merged, by the repo's raw past rate in tenths.
|
|
118
|
+
cells = {}
|
|
119
|
+
for _, _, pc, pm, fn, fm in items:
|
|
120
|
+
c = cells.setdefault(min(int(pm / pc * 10), 9), [0, 0.0, 0])
|
|
121
|
+
c[0] += fn
|
|
122
|
+
c[1] += pm / pc * fn
|
|
123
|
+
c[2] += fm
|
|
124
|
+
return cells
|
|
125
|
+
# The correction is measured on repos it was not built from: fit on one half, check on the other, and swap.
|
|
126
|
+
halves = (repos[0::2], repos[1::2])
|
|
127
|
+
err["calibrated"] = 0.0
|
|
128
|
+
for fit, check in ((halves[0], halves[1]), (halves[1], halves[0])):
|
|
129
|
+
cells = table(fit)
|
|
130
|
+
for _, _, pc, pm, fn, fm in check:
|
|
131
|
+
c = cells.get(min(int(pm / pc * 10), 9))
|
|
132
|
+
err["calibrated"] += abs((c[2] / c[0] if c else pm / pc) - fm / fn) * fn
|
|
133
|
+
out["calibration_raw"] = [{"bin": f"{i / 10:.1f}-{(i + 1) / 10:.1f}", "prs": n, "predicted": round(s / n, 4),
|
|
134
|
+
"observed": round(m / n, 4)} for i, (n, s, m) in sorted(table(repos).items())]
|
|
135
|
+
out.update(typical=round(typical["all|all"], 4), mae={name: round(e / total, 4) for name, e in err.items()},
|
|
136
|
+
by_past_prs={label: ({"repos": buckets[label]["repos"], "prs": buckets[label]["prs"],
|
|
137
|
+
"mae": {key: round(e / buckets[label]["prs"], 4) for key, e in buckets[label]["err"].items()}}
|
|
138
|
+
if label in buckets else {"repos": 0, "prs": 0}) for label, _, _ in BUCKETS},
|
|
139
|
+
calibration=[{"bin": f"{i / 10:.1f}-{(i + 1) / 10:.1f}", "prs": n, "predicted": round(s / n, 4),
|
|
140
|
+
"observed": round(m / n, 4)} for i, (n, s, m) in sorted(bins.items())])
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
MIN_AGENT_REPOS = 20
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def agent_prs(con: duckdb.DuckDBPyConnection, now: datetime, window_days: int = 180, min_each: int = 5) -> dict:
|
|
148
|
+
"""PRs opened by coding agents next to people's outside PRs in the same repos (each with min_each+ decided PRs of
|
|
149
|
+
both kinds): the median repo's merge share of each, per group of similar repos with 20+ such repos. Agents are known
|
|
150
|
+
by their account, as the suite's app knows them (an AI-assisted PR by a person stays that person's), and a PR counts
|
|
151
|
+
in the window it was decided in."""
|
|
152
|
+
rows = con.execute("""
|
|
153
|
+
SELECT p.repo_id, any_value(rm.language), any_value(rm.stars),
|
|
154
|
+
count(*) FILTER (WHERE p.author_role = 'agent'), count(*) FILTER (WHERE p.author_role = 'agent' AND p.state = 'MERGED'),
|
|
155
|
+
count(*) FILTER (WHERE p.author_role = 'outsider'),
|
|
156
|
+
count(*) FILTER (WHERE p.author_role = 'outsider' AND p.state = 'MERGED')
|
|
157
|
+
FROM pr_class p LEFT JOIN repo_meta rm USING (repo_id)
|
|
158
|
+
WHERE coalesce(p.merged_at, p.closed_at) >= $since AND p.state IN ('MERGED', 'CLOSED')
|
|
159
|
+
AND p.author_role IN ('agent', 'outsider')
|
|
160
|
+
GROUP BY p.repo_id""", {"since": now - timedelta(days=window_days)}).fetchall()
|
|
161
|
+
groups = {}
|
|
162
|
+
for _, lang, stars, an, am, on, om in rows:
|
|
163
|
+
if an < min_each or on < min_each:
|
|
164
|
+
continue
|
|
165
|
+
lang = lang or "Other"
|
|
166
|
+
for key in (f"{lang}|{tier(stars)}", f"{lang}|all", f"all|{tier(stars)}", "all|all"):
|
|
167
|
+
groups.setdefault(key, []).append((an, am / an, on, om / on))
|
|
168
|
+
return {key: {"repos_with_agent_prs": len(g),
|
|
169
|
+
"agent": {"decided": sum(x[0] for x in g), "merged": round(_median([x[1] for x in g]), 4)},
|
|
170
|
+
"outsider": {"decided": sum(x[2] for x in g), "merged": round(_median([x[3] for x in g]), 4)}}
|
|
171
|
+
for key, g in groups.items() if len(g) >= MIN_AGENT_REPOS}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
MIN_LEADER_REPOS = 10 # an agent seen in fewer repos is left off the leaderboard
|
|
175
|
+
# Which agent, from its account's name or the branch prefix it pushes from a person's account.
|
|
176
|
+
AGENT_NAME = """CASE
|
|
177
|
+
WHEN p.author_role = 'agent' THEN coalesce(
|
|
178
|
+
nullif(regexp_extract(lower(p.author_login), '(copilot|devin|claude|codex|cursor|openhands|sweep|jules|codegen)', 1), ''),
|
|
179
|
+
'other agent')
|
|
180
|
+
ELSE lower(regexp_extract(coalesce(p.head_ref, ''), '^(copilot|codex|cursor|devin|claude)[/-]', 1)) END"""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def agent_leaderboard(con: duckdb.DuckDBPyConnection, now: datetime, window_days: int = 180) -> dict:
|
|
184
|
+
"""Each coding agent across the crawl's public repos: repos and decided PRs, the share merged, the share merged by
|
|
185
|
+
someone other than its author, and its gap against people's PRs of the same repo, month and size (Mantel-Haenszel,
|
|
186
|
+
95% range); agents seen in fewer than 10 repos are left out."""
|
|
187
|
+
rows = con.execute(f"""
|
|
188
|
+
SELECT p.repo_id, strftime(coalesce(p.merged_at, p.closed_at), '%Y-%m') AS month,
|
|
189
|
+
CAST(floor(log2(coalesce(p.additions, 0) + coalesce(p.deletions, 0) + 1)) AS INTEGER) AS band,
|
|
190
|
+
CASE WHEN p.author_role = 'agent' OR regexp_matches(coalesce(p.head_ref, ''), '^(copilot|codex|cursor|devin|claude)[/-]', 'i')
|
|
191
|
+
THEN {AGENT_NAME} END AS agent,
|
|
192
|
+
count(*), count(*) FILTER (WHERE p.state = 'MERGED'),
|
|
193
|
+
count(*) FILTER (WHERE p.state = 'MERGED' AND lower(coalesce(p.merged_by, '')) <> lower(coalesce(p.author_login, '')))
|
|
194
|
+
FROM pr_class p
|
|
195
|
+
WHERE coalesce(p.merged_at, p.closed_at) >= $since AND p.state IN ('MERGED', 'CLOSED')
|
|
196
|
+
AND p.author_role IN ('agent', 'insider', 'outsider')
|
|
197
|
+
GROUP BY p.repo_id, month, band, agent""", {"since": now - timedelta(days=window_days)}).fetchall()
|
|
198
|
+
people, mine = {}, {}
|
|
199
|
+
for repo, month, band, agent, n, merged, others in rows:
|
|
200
|
+
if agent:
|
|
201
|
+
mine.setdefault(agent, {})[(repo, month, band)] = (n, merged, others)
|
|
202
|
+
else:
|
|
203
|
+
people[(repo, month, band)] = (n, merged)
|
|
204
|
+
agents = {}
|
|
205
|
+
for name, cells in mine.items():
|
|
206
|
+
repos = {k[0] for k in cells}
|
|
207
|
+
if len(repos) < MIN_LEADER_REPOS:
|
|
208
|
+
continue
|
|
209
|
+
n, merged, others = (sum(c[i] for c in cells.values()) for i in range(3))
|
|
210
|
+
pairs = [(c[1], c[0], people[k][1], people[k][0]) for k, c in cells.items() if k in people and people[k][0]]
|
|
211
|
+
entry = {"repos": len(repos), "prs": n, "merged": round(merged / n, 4), "merged_by_others": round(others / n, 4)}
|
|
212
|
+
if pairs:
|
|
213
|
+
rd, se = mh_risk_difference(pairs)
|
|
214
|
+
entry["vs_people"] = {"gap": round(rd, 4), "low": round(rd - 1.96 * se, 4), "high": round(rd + 1.96 * se, 4),
|
|
215
|
+
"prs_compared": sum(p[1] for p in pairs)}
|
|
216
|
+
agents[name] = entry
|
|
217
|
+
return {"window_days": window_days, "agents": dict(sorted(agents.items(), key=lambda kv: -kv[1]["prs"]))}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def agent_matched(con: duckdb.DuckDBPyConnection, now: datetime, window_days: int = 180) -> dict:
|
|
221
|
+
"""Coding agents' PRs (their account, or an agent's branch such as codex/ opened from a person's account) against
|
|
222
|
+
people's PRs of the same repo, month and size, per group of similar repos with 20+ such repos: the gap in merged and
|
|
223
|
+
in merged by someone other than the author (Mantel-Haenszel risk difference, 95% range). Most agents' PRs are merged
|
|
224
|
+
by whoever ran the agent, so the second gap is the one that says whether anyone else accepted them."""
|
|
225
|
+
rows = con.execute("""
|
|
226
|
+
SELECT p.repo_id, any_value(rm.language), any_value(rm.stars),
|
|
227
|
+
strftime(coalesce(p.merged_at, p.closed_at), '%Y-%m') AS month,
|
|
228
|
+
CAST(floor(log2(coalesce(p.additions, 0) + coalesce(p.deletions, 0) + 1)) AS INTEGER) AS band,
|
|
229
|
+
(p.author_role = 'agent' OR regexp_matches(coalesce(p.head_ref, ''), '^(copilot|codex|cursor|devin|claude)[/-]', 'i')) AS agent,
|
|
230
|
+
count(*), count(*) FILTER (WHERE p.state = 'MERGED'),
|
|
231
|
+
count(*) FILTER (WHERE p.state = 'MERGED' AND lower(coalesce(p.merged_by, '')) <> lower(coalesce(p.author_login, '')))
|
|
232
|
+
FROM pr_class p LEFT JOIN repo_meta rm USING (repo_id)
|
|
233
|
+
WHERE coalesce(p.merged_at, p.closed_at) >= $since AND p.state IN ('MERGED', 'CLOSED')
|
|
234
|
+
AND p.author_role IN ('agent', 'insider', 'outsider')
|
|
235
|
+
GROUP BY p.repo_id, month, band, agent""", {"since": now - timedelta(days=window_days)}).fetchall()
|
|
236
|
+
strata, meta = {}, {}
|
|
237
|
+
for repo, lang, stars, month, band, agent, n, merged, others in rows:
|
|
238
|
+
cell = strata.setdefault((repo, month, band), [0, 0, 0, 0, 0, 0])
|
|
239
|
+
at = 0 if agent else 3
|
|
240
|
+
cell[at], cell[at + 1], cell[at + 2] = n, merged, others
|
|
241
|
+
meta[repo] = (lang or "Other", stars)
|
|
242
|
+
groups = {}
|
|
243
|
+
for (repo, _month, _band), c in strata.items():
|
|
244
|
+
if c[0] and c[3]:
|
|
245
|
+
lang, stars = meta[repo]
|
|
246
|
+
for key in (f"{lang}|{tier(stars)}", f"{lang}|all", f"all|{tier(stars)}", "all|all"):
|
|
247
|
+
groups.setdefault(key, []).append((repo, c))
|
|
248
|
+
out = {}
|
|
249
|
+
for key, cells in groups.items():
|
|
250
|
+
repos = {repo for repo, _c in cells}
|
|
251
|
+
if len(repos) < MIN_AGENT_REPOS:
|
|
252
|
+
continue
|
|
253
|
+
found = {"repos": len(repos), "agent_prs": sum(c[0] for _r, c in cells)}
|
|
254
|
+
for name, hit in (("merged", 1), ("merged_by_others", 2)):
|
|
255
|
+
rd, se = mh_risk_difference([(c[hit], c[0], c[3 + hit], c[3]) for _r, c in cells])
|
|
256
|
+
found[name] = {"gap": round(rd, 4), "low": round(rd - 1.96 * se, 4), "high": round(rd + 1.96 * se, 4)}
|
|
257
|
+
out[key] = found
|
|
258
|
+
return out
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import duckdb
|
|
2
|
+
|
|
3
|
+
from mergescout.signals.rules import (AI_AGENT_BOT_NAMES, AI_AGENT_LOGINS, BOT_NAME_PATTERN, HUMAN_BOT_NAMES,
|
|
4
|
+
KNOWN_BOTS, UNKNOWN_LOGINS)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _quoted(names) -> str:
|
|
8
|
+
return ", ".join("'" + n.replace("'", "''") + "'" for n in sorted(names))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_people(con: duckdb.DuckDBPyConnection) -> None:
|
|
12
|
+
# Insiders are found by what they do (merging, listed roles), not only by GitHub's association label.
|
|
13
|
+
con.execute(f"""
|
|
14
|
+
CREATE OR REPLACE TABLE people AS
|
|
15
|
+
WITH acts AS (
|
|
16
|
+
SELECT repo_id, author_login AS login, author_association AS assoc, author_type AS typ, 'pr' AS act FROM prs
|
|
17
|
+
UNION ALL SELECT repo_id, merged_by, NULL, NULL, 'merged_other' FROM prs
|
|
18
|
+
WHERE state = 'MERGED' AND merged_by IS NOT NULL AND merged_by IS DISTINCT FROM author_login
|
|
19
|
+
UNION ALL SELECT repo_id, merged_by, NULL, NULL, 'self_merge' FROM prs
|
|
20
|
+
WHERE state = 'MERGED' AND merged_by = author_login
|
|
21
|
+
UNION ALL SELECT repo_id, first_review_by, first_review_association, NULL, 'review' FROM prs
|
|
22
|
+
UNION ALL SELECT repo_id, first_human_review_by, first_human_review_association, NULL, 'review' FROM prs
|
|
23
|
+
UNION ALL SELECT repo_id, author_login, author_association, NULL, 'issue' FROM issues
|
|
24
|
+
UNION ALL SELECT repo_id, author_login, author_association, author_type, 'comment' FROM comments
|
|
25
|
+
UNION ALL SELECT repo_id, author_login, author_association, NULL, 'issue' FROM closed_issues),
|
|
26
|
+
agg AS (
|
|
27
|
+
SELECT repo_id, login,
|
|
28
|
+
bool_or(typ = 'Bot' OR lower(login) LIKE '%[bot]' OR lower(login) IN ({_quoted(KNOWN_BOTS)})
|
|
29
|
+
OR (regexp_matches(lower(login), '{BOT_NAME_PATTERN}')
|
|
30
|
+
AND NOT regexp_matches(lower(login), '{HUMAN_BOT_NAMES}'))) AS bot,
|
|
31
|
+
bool_or(assoc IN ('OWNER', 'MEMBER', 'COLLABORATOR')) AS listed,
|
|
32
|
+
bool_or(act = 'merged_other') AS merges_others,
|
|
33
|
+
bool_or(act = 'self_merge') AS self_merges,
|
|
34
|
+
count(*) AS activity,
|
|
35
|
+
count(*) FILTER (WHERE act = 'pr') AS prs
|
|
36
|
+
FROM acts
|
|
37
|
+
WHERE login IS NOT NULL AND lower(login) NOT IN ({_quoted(UNKNOWN_LOGINS)})
|
|
38
|
+
GROUP BY repo_id, login),
|
|
39
|
+
-- People named in CODEOWNERS / MAINTAINERS / OWNERS are maintainers even when they rarely merge.
|
|
40
|
+
owners AS (
|
|
41
|
+
SELECT DISTINCT repo_id, lower(o) AS l FROM (SELECT repo_id, unnest(owner_logins) AS o FROM repo_meta))
|
|
42
|
+
SELECT a.repo_id, a.login,
|
|
43
|
+
CASE WHEN bot THEN 'bot' WHEN listed OR merges_others OR self_merges OR ow.l IS NOT NULL THEN 'insider'
|
|
44
|
+
ELSE 'outsider' END AS kind,
|
|
45
|
+
concat_ws(', ', CASE WHEN bot THEN 'bot account' END, CASE WHEN listed THEN 'listed role' END,
|
|
46
|
+
CASE WHEN merges_others THEN 'merges others' END, CASE WHEN self_merges THEN 'merges own' END,
|
|
47
|
+
CASE WHEN ow.l IS NOT NULL THEN 'in owner files' END) AS reasons,
|
|
48
|
+
activity, prs
|
|
49
|
+
FROM agg a LEFT JOIN owners ow ON ow.repo_id = a.repo_id AND ow.l = lower(a.login)""")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_pr_class(con: duckdb.DuckDBPyConnection) -> None:
|
|
53
|
+
con.execute(f"""
|
|
54
|
+
CREATE OR REPLACE VIEW pr_class AS
|
|
55
|
+
SELECT p.*, CASE
|
|
56
|
+
WHEN p.author_login IS NULL OR lower(p.author_login) IN ({_quoted(UNKNOWN_LOGINS)}) THEN 'unknown'
|
|
57
|
+
WHEN replace(lower(p.author_login), '[bot]', '') IN ({_quoted(AI_AGENT_LOGINS)}) THEN 'ai'
|
|
58
|
+
WHEN replace(lower(p.author_login), '[bot]', '') IN ({_quoted(AI_AGENT_BOT_NAMES)})
|
|
59
|
+
AND (p.author_type = 'Bot' OR lower(p.author_login) LIKE '%[bot]') THEN 'ai'
|
|
60
|
+
WHEN pe.kind = 'bot' OR p.ai_tag = 'bot' THEN 'bot'
|
|
61
|
+
WHEN p.ai_tag IS NOT NULL THEN 'ai'
|
|
62
|
+
WHEN pe.kind = 'insider' THEN 'insider'
|
|
63
|
+
ELSE 'outsider' END AS author_class,
|
|
64
|
+
-- The author alone, as the suite's app decides it: a person's AI-assisted PR is still that person's.
|
|
65
|
+
CASE
|
|
66
|
+
WHEN p.author_login IS NULL OR lower(p.author_login) IN ({_quoted(UNKNOWN_LOGINS)}) THEN 'unknown'
|
|
67
|
+
WHEN replace(lower(p.author_login), '[bot]', '') IN ({_quoted(AI_AGENT_LOGINS)}) THEN 'agent'
|
|
68
|
+
WHEN replace(lower(p.author_login), '[bot]', '') IN ({_quoted(AI_AGENT_BOT_NAMES)})
|
|
69
|
+
AND (p.author_type = 'Bot' OR lower(p.author_login) LIKE '%[bot]') THEN 'agent'
|
|
70
|
+
WHEN pe.kind = 'bot' OR p.ai_tag = 'bot' THEN 'bot'
|
|
71
|
+
WHEN pe.kind = 'insider' THEN 'insider'
|
|
72
|
+
ELSE 'outsider' END AS author_role
|
|
73
|
+
FROM prs p LEFT JOIN people pe ON pe.repo_id = p.repo_id AND pe.login = p.author_login""")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
import duckdb
|
|
4
|
+
|
|
5
|
+
from mergescout.labels import BEGINNER, HOT
|
|
6
|
+
from mergescout.signals.identity import _quoted
|
|
7
|
+
from mergescout.signals.rules import (CLAIM_PATTERN, DISCUSSION_LABEL, DISCUSSION_TITLE, RULES, TRIAGE_LABEL_PATTERN,
|
|
8
|
+
UNKNOWN_LOGINS, WAIT_LABEL_PATTERN, WAIT_PATTERN, Rules)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def norm_label_macro(con: duckdb.DuckDBPyConnection) -> None:
|
|
12
|
+
# Same rule as labels.norm_label: lower case, drop :emoji:, keep letters and digits, single spaces.
|
|
13
|
+
con.execute(r"""CREATE OR REPLACE MACRO norm_label(x) AS trim(regexp_replace(
|
|
14
|
+
regexp_replace(regexp_replace(lower(x), ':[a-z0-9_+-]+:', ' ', 'g'), '[^a-z0-9]+', ' ', 'g'), ' +', ' ', 'g'))""")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_issue_signals(con: duckdb.DuckDBPyConnection, now: datetime, rules: Rules = RULES) -> None:
|
|
18
|
+
norm_label_macro(con)
|
|
19
|
+
claim_since = now - timedelta(days=rules.claim_days)
|
|
20
|
+
interest_since = now - timedelta(days=rules.interest_days)
|
|
21
|
+
con.execute(f"""
|
|
22
|
+
CREATE OR REPLACE TABLE issue_signals AS
|
|
23
|
+
WITH c AS (
|
|
24
|
+
SELECT c.*, coalesce(pe.kind, 'outsider') AS kind FROM comments c
|
|
25
|
+
LEFT JOIN people pe ON pe.repo_id = c.repo_id AND pe.login = c.author_login
|
|
26
|
+
WHERE c.author_login IS NOT NULL AND lower(c.author_login) NOT IN ({_quoted(UNKNOWN_LOGINS)})),
|
|
27
|
+
claims AS (
|
|
28
|
+
SELECT repo_id, number, max(created_at) AS last_claim_at, count(DISTINCT author_login) AS claimers
|
|
29
|
+
FROM c WHERE kind = 'outsider' AND regexp_matches(lower(body_excerpt), $claim) GROUP BY ALL),
|
|
30
|
+
waits AS (
|
|
31
|
+
SELECT repo_id, number, max(created_at) AS wait_at
|
|
32
|
+
FROM c WHERE kind = 'insider' AND regexp_matches(lower(body_excerpt), $wait) GROUP BY ALL),
|
|
33
|
+
interest AS (
|
|
34
|
+
SELECT repo_id, number, count(DISTINCT author_login) AS interest_30d
|
|
35
|
+
FROM c WHERE kind = 'outsider' AND created_at >= $interest_since GROUP BY ALL),
|
|
36
|
+
il AS (
|
|
37
|
+
SELECT *, list_transform(coalesce(labels, []), lambda x: norm_label(x)) AS nl FROM issues),
|
|
38
|
+
i AS (
|
|
39
|
+
SELECT *, len(list_filter(nl, lambda x: regexp_matches(x, $wait_label))) > 0 AS wait_label,
|
|
40
|
+
len(list_filter(nl, lambda x: regexp_matches(x, $triage_label))) > 0 AS triage_label,
|
|
41
|
+
list_has_any(nl, $hot) AS opportunity
|
|
42
|
+
FROM il)
|
|
43
|
+
SELECT i.repo_id, i.number, i.title, i.labels,
|
|
44
|
+
i.opportunity, list_has_any(i.nl, $beginner) AS beginner, i.triage_label,
|
|
45
|
+
CASE WHEN regexp_matches(lower(coalesce(i.title, '')), $disc_title)
|
|
46
|
+
OR len(list_filter(i.nl, lambda x: regexp_matches(x, $disc_label))) > 0
|
|
47
|
+
THEN 'discussion' ELSE 'task' END AS kind,
|
|
48
|
+
coalesce(i.assignees, 0) AS assignees, coalesce(i.linked_open_prs, 0) AS linked_open_prs,
|
|
49
|
+
coalesce(i.linked_closed_prs, 0) AS dead_ends,
|
|
50
|
+
cl.last_claim_at, coalesce(cl.claimers, 0) AS claimers, w.wait_at,
|
|
51
|
+
coalesce(it.interest_30d, 0) AS interest_30d,
|
|
52
|
+
(coalesce(i.assignees, 0) = 0 AND coalesce(i.linked_open_prs, 0) = 0
|
|
53
|
+
AND (cl.last_claim_at IS NULL OR cl.last_claim_at < $claim_since) AND w.wait_at IS NULL
|
|
54
|
+
AND NOT i.wait_label AND NOT (i.triage_label AND NOT i.opportunity)) AS available,
|
|
55
|
+
concat_ws(', ', CASE WHEN i.assignees > 0 THEN 'assigned' END,
|
|
56
|
+
CASE WHEN i.linked_open_prs > 0 THEN 'open PR' END,
|
|
57
|
+
CASE WHEN cl.last_claim_at >= $claim_since THEN 'claimed recently' END,
|
|
58
|
+
CASE WHEN w.wait_at IS NOT NULL THEN 'maintainer asked to wait' END,
|
|
59
|
+
CASE WHEN i.wait_label THEN 'label says not ready' END,
|
|
60
|
+
CASE WHEN i.triage_label AND NOT i.opportunity THEN 'label says not settled yet' END)
|
|
61
|
+
AS blocked_by,
|
|
62
|
+
i.created_at, i.updated_at, i.fetched_at, i.run_id
|
|
63
|
+
FROM i
|
|
64
|
+
LEFT JOIN claims cl USING (repo_id, number) LEFT JOIN waits w USING (repo_id, number)
|
|
65
|
+
LEFT JOIN interest it USING (repo_id, number)
|
|
66
|
+
-- Bot-made issues (CI failures, CVE alerts) are not tasks unless a beginner or help label invites outsiders:
|
|
67
|
+
-- real bots also mirror maintainer tasks (stdlib-bot lint fixes, MelvinBot "Help Wanted").
|
|
68
|
+
LEFT JOIN people au ON au.repo_id = i.repo_id AND au.login = i.author_login
|
|
69
|
+
WHERE i.state = 'OPEN' AND NOT (coalesce(au.kind, '') = 'bot' AND NOT list_has_any(i.nl, $hot))
|
|
70
|
+
AND NOT regexp_matches(lower(coalesce(i.title, '')), $dashboard) AND NOT EXISTS (
|
|
71
|
+
SELECT 1 FROM issue_events e WHERE e.repo_id = i.repo_id AND e.number = i.number AND e.event = 'gone'
|
|
72
|
+
AND e.observed_at::DATE >= coalesce(i.fetched_at::DATE, DATE '1970-01-01'))""",
|
|
73
|
+
{"claim": CLAIM_PATTERN, "wait": WAIT_PATTERN, "interest_since": interest_since,
|
|
74
|
+
"claim_since": claim_since, "hot": sorted(HOT), "beginner": sorted(BEGINNER),
|
|
75
|
+
"wait_label": WAIT_LABEL_PATTERN, "triage_label": TRIAGE_LABEL_PATTERN, "disc_title": DISCUSSION_TITLE,
|
|
76
|
+
"disc_label": DISCUSSION_LABEL, "dashboard": r"^\s*dependency dashboard\s*$"})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
import duckdb
|
|
4
|
+
|
|
5
|
+
from mergescout.labels import HOT
|
|
6
|
+
from mergescout.signals.issues import norm_label_macro
|
|
7
|
+
from mergescout.signals.repo_stats import _wilson_macros
|
|
8
|
+
from mergescout.signals.rules import RULES, Rules
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_label_trust(con: duckdb.DuckDBPyConnection, now: datetime, rules: Rules = RULES) -> None:
|
|
12
|
+
norm_label_macro(con)
|
|
13
|
+
_wilson_macros(con)
|
|
14
|
+
con.execute("""
|
|
15
|
+
CREATE OR REPLACE TABLE label_trust AS
|
|
16
|
+
WITH closed AS (
|
|
17
|
+
-- Refreshes bring closed issues as issue rows; only crawlers that record fixes fill linked_closed_prs.
|
|
18
|
+
SELECT DISTINCT ON (repo_id, number) *
|
|
19
|
+
FROM (SELECT repo_id, number, labels, state_reason, fix_pr, fix_pr_state, fix_author, fix_association,
|
|
20
|
+
closed_at, fetched_at FROM closed_issues
|
|
21
|
+
UNION ALL
|
|
22
|
+
SELECT repo_id, number, labels, state_reason, fix_pr, fix_pr_state, fix_author, fix_association,
|
|
23
|
+
closed_at, fetched_at FROM issues WHERE state = 'CLOSED' AND linked_closed_prs IS NOT NULL)
|
|
24
|
+
WHERE closed_at >= $since
|
|
25
|
+
ORDER BY repo_id, number, fetched_at DESC NULLS LAST),
|
|
26
|
+
ci AS (
|
|
27
|
+
SELECT DISTINCT ON (repo_id, number, label) *
|
|
28
|
+
FROM (SELECT *, unnest(list_transform(labels, lambda x: norm_label(x))) AS label FROM closed)
|
|
29
|
+
WHERE list_contains($hot, label)),
|
|
30
|
+
judged AS (
|
|
31
|
+
SELECT ci.repo_id, ci.label, ci.number, ci.fix_pr, CASE
|
|
32
|
+
WHEN ci.state_reason = 'NOT_PLANNED' THEN 'not_planned'
|
|
33
|
+
WHEN ci.state_reason = 'DUPLICATE' THEN 'duplicate'
|
|
34
|
+
WHEN ci.fix_pr_state IS DISTINCT FROM 'MERGED' THEN 'other'
|
|
35
|
+
WHEN pe.kind = 'bot' OR p.ai_tag = 'bot' THEN 'bot'
|
|
36
|
+
WHEN p.ai_tag IS NOT NULL THEN 'ai'
|
|
37
|
+
WHEN pe.kind = 'insider' OR ci.fix_association IN ('OWNER', 'MEMBER', 'COLLABORATOR') THEN 'insider'
|
|
38
|
+
WHEN ci.fix_author IS NULL THEN 'other'
|
|
39
|
+
ELSE 'outsider' END AS outcome
|
|
40
|
+
FROM ci
|
|
41
|
+
LEFT JOIN people pe ON pe.repo_id = ci.repo_id AND pe.login = ci.fix_author
|
|
42
|
+
LEFT JOIN prs p ON p.repo_id = ci.repo_id AND p.number = ci.fix_pr),
|
|
43
|
+
sizes AS (
|
|
44
|
+
SELECT j.repo_id, j.label, median(p.additions + p.deletions) AS median_fix_lines,
|
|
45
|
+
median(p.changed_files) AS median_fix_files, count(p.number) AS sized
|
|
46
|
+
FROM judged j JOIN prs p ON p.repo_id = j.repo_id AND p.number = j.fix_pr
|
|
47
|
+
WHERE j.outcome IN ('outsider', 'insider') GROUP BY ALL),
|
|
48
|
+
stale AS (
|
|
49
|
+
SELECT repo_id, label, count(*) AS open_stale
|
|
50
|
+
FROM (SELECT repo_id, created_at, unnest(list_transform(labels, lambda x: norm_label(x))) AS label
|
|
51
|
+
FROM issues WHERE state = 'OPEN')
|
|
52
|
+
WHERE list_contains($hot, label) AND created_at < $stale_before GROUP BY ALL),
|
|
53
|
+
counts AS (
|
|
54
|
+
SELECT repo_id, label, count(*) AS closed_n,
|
|
55
|
+
count(*) FILTER (WHERE outcome = 'outsider') AS outsider_fixes,
|
|
56
|
+
count(*) FILTER (WHERE outcome = 'insider') AS insider_fixes,
|
|
57
|
+
count(*) FILTER (WHERE outcome = 'ai') AS ai_fixes,
|
|
58
|
+
count(*) FILTER (WHERE outcome = 'bot') AS bot_fixes,
|
|
59
|
+
count(*) FILTER (WHERE outcome = 'not_planned') AS not_planned,
|
|
60
|
+
count(*) FILTER (WHERE outcome = 'duplicate') AS duplicates,
|
|
61
|
+
count(*) FILTER (WHERE outcome = 'other') AS other,
|
|
62
|
+
-- The share only counts fixes whose author is known; unlinked closes say nothing about who fixed them.
|
|
63
|
+
count(*) FILTER (WHERE outcome IN ('outsider', 'insider', 'ai', 'bot')) AS known_fixes
|
|
64
|
+
FROM judged GROUP BY ALL)
|
|
65
|
+
SELECT k.repo_id, k.label, coalesce(c.closed_n, 0) AS closed_n,
|
|
66
|
+
coalesce(c.outsider_fixes, 0) AS outsider_fixes, coalesce(c.insider_fixes, 0) AS insider_fixes,
|
|
67
|
+
coalesce(c.ai_fixes, 0) AS ai_fixes, coalesce(c.bot_fixes, 0) AS bot_fixes,
|
|
68
|
+
coalesce(c.not_planned, 0) AS not_planned, coalesce(c.duplicates, 0) AS duplicates,
|
|
69
|
+
coalesce(c.other, 0) AS other, coalesce(c.known_fixes, 0) AS known_fixes,
|
|
70
|
+
c.outsider_fixes::DOUBLE / nullif(c.known_fixes, 0) AS outsider_share,
|
|
71
|
+
wilson_lo(c.outsider_fixes, c.known_fixes) AS outsider_low,
|
|
72
|
+
wilson_hi(c.outsider_fixes, c.known_fixes) AS outsider_high,
|
|
73
|
+
coalesce(c.known_fixes, 0) >= $min_closed AS enough,
|
|
74
|
+
s.median_fix_lines, s.median_fix_files, coalesce(s.sized, 0) AS sized, coalesce(st.open_stale, 0) AS open_stale
|
|
75
|
+
FROM (SELECT repo_id, label FROM counts UNION SELECT repo_id, label FROM stale) k
|
|
76
|
+
LEFT JOIN counts c USING (repo_id, label) LEFT JOIN sizes s USING (repo_id, label)
|
|
77
|
+
LEFT JOIN stale st USING (repo_id, label)""",
|
|
78
|
+
{"hot": sorted(HOT), "stale_before": now - timedelta(days=rules.stale_days), "min_closed": rules.min_closed,
|
|
79
|
+
"since": now - timedelta(days=rules.window_days)})
|