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,149 @@
|
|
|
1
|
+
"""How public repos treat outside contributors, from the crawl: how fast a first answer comes, and whether newcomers come
|
|
2
|
+
back. Per-repo facts become percentiles by language and size; the newcomer table backs the "answer first-timers" lever."""
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
import duckdb
|
|
6
|
+
|
|
7
|
+
from mergescout.bench.ci import _pct, quantiles, tier
|
|
8
|
+
|
|
9
|
+
MIN_PRS = 10
|
|
10
|
+
MIN_NEWCOMERS = 10
|
|
11
|
+
MIN_GROUP = 50
|
|
12
|
+
# A repo's first 60 days of crawled history can't tell a newcomer from someone who came before it.
|
|
13
|
+
HISTORY_DAYS = 60
|
|
14
|
+
RETURN_DAYS = 90
|
|
15
|
+
METRICS = ("first_answer_hours", "answered_1d_share", "newcomer_answered_share", "newcomer_return_rate", "bus_factor", "top_share")
|
|
16
|
+
DEPENDENCY_DAYS, MIN_MERGED = 180, 20
|
|
17
|
+
BUCKETS = ("within 1 day", "1-3 days", "3-14 days", "over 14 days", "no answer")
|
|
18
|
+
DEFINITION = ("Outside contributors are people who are not owners, members, collaborators, bots or AI agents. A first "
|
|
19
|
+
"answer is the first review by someone other than the author, a merge or a close. Per repo: median hours "
|
|
20
|
+
"to the first answer and the share answered within a day, for outside PRs opened in the last 90 days "
|
|
21
|
+
"(10+ PRs); for newcomers (an outside contributor's first PR in a repo with 60+ days of history before "
|
|
22
|
+
"it), the share answered within 14 days and the share who opened another PR 1 to 90 days later (10+ newcomers). "
|
|
23
|
+
"Accounts opening 20+ PRs in a repo on one day are left out as automated. Who carries the work: PRs by "
|
|
24
|
+
"people (anyone but bots and AI agents) merged in the last 180 days (20+); the top person's share, and the "
|
|
25
|
+
"bus factor, the fewest people behind half of them.")
|
|
26
|
+
|
|
27
|
+
ANSWER = """least(CASE WHEN p.first_human_review_by IS DISTINCT FROM p.author_login THEN p.first_human_review_at END,
|
|
28
|
+
p.merged_at, p.closed_at)"""
|
|
29
|
+
# One account opening 20+ PRs in a repo on one day is automated (bat: 73 in a day); it would swamp the repo's rates.
|
|
30
|
+
BURST = """burst AS (SELECT DISTINCT repo_id, author_login FROM pr_class WHERE author_class = 'outsider'
|
|
31
|
+
GROUP BY repo_id, author_login, CAST(created_at AS DATE) HAVING count(*) >= 20)"""
|
|
32
|
+
NEWCOMERS = f"""
|
|
33
|
+
WITH span AS (SELECT repo_id, min(created_at) AS start FROM pr_class GROUP BY repo_id),
|
|
34
|
+
{BURST},
|
|
35
|
+
outside AS (SELECT p.*, {ANSWER} AS answered_at FROM pr_class p
|
|
36
|
+
WHERE p.author_class = 'outsider' AND NOT EXISTS (
|
|
37
|
+
SELECT 1 FROM burst b WHERE b.repo_id = p.repo_id AND b.author_login = p.author_login)),
|
|
38
|
+
ranked AS (SELECT o.*, row_number() OVER w AS k
|
|
39
|
+
FROM outside o WINDOW w AS (PARTITION BY o.repo_id, o.author_login ORDER BY o.created_at, o.number)),
|
|
40
|
+
firsts AS (SELECT r.* FROM ranked r JOIN span s USING (repo_id)
|
|
41
|
+
WHERE r.k = 1 AND r.created_at >= s.start + INTERVAL {HISTORY_DAYS} DAY)
|
|
42
|
+
-- Coming back is new work at least a day later: PRs split minutes apart are one contribution.
|
|
43
|
+
SELECT f.*, (SELECT min(o.created_at) FROM outside o WHERE o.repo_id = f.repo_id AND o.author_login = f.author_login
|
|
44
|
+
AND o.created_at >= f.created_at + INTERVAL 1 DAY) AS next_at
|
|
45
|
+
FROM firsts f"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def newcomer_returns(con: duckdb.DuckDBPyConnection, now: datetime) -> dict:
|
|
49
|
+
"""Newcomers whose return can be judged (first PR 120+ days ago), by how long their first PR waited for an answer."""
|
|
50
|
+
judged = f"WITH judged AS (SELECT * FROM ({NEWCOMERS}) WHERE created_at <= $cut)"
|
|
51
|
+
cut = {"cut": now - timedelta(days=RETURN_DAYS + 30)}
|
|
52
|
+
back = f"avg((next_at IS NOT NULL AND next_at <= created_at + INTERVAL {RETURN_DAYS} DAY)::INT)"
|
|
53
|
+
by_answer = con.execute(f"""{judged}
|
|
54
|
+
SELECT CASE WHEN answered_at IS NULL THEN 4 WHEN answered_at - created_at <= INTERVAL 1 DAY THEN 0
|
|
55
|
+
WHEN answered_at - created_at <= INTERVAL 3 DAY THEN 1 WHEN answered_at - created_at <= INTERVAL 14 DAY THEN 2
|
|
56
|
+
ELSE 3 END AS b, count(*), {back}, avg((merged_at IS NOT NULL)::INT)
|
|
57
|
+
FROM judged GROUP BY 1 ORDER BY 1""", cut).fetchall()
|
|
58
|
+
by_outcome = con.execute(f"""{judged}
|
|
59
|
+
SELECT CASE WHEN merged_at IS NOT NULL THEN 'merged' WHEN state = 'CLOSED' THEN 'closed unmerged' ELSE 'still open' END,
|
|
60
|
+
count(*), {back}
|
|
61
|
+
FROM judged GROUP BY 1 ORDER BY 2 DESC""", cut).fetchall()
|
|
62
|
+
n, repos = con.execute(f"{judged} SELECT count(*), count(DISTINCT repo_id) FROM judged", cut).fetchall()[0]
|
|
63
|
+
return {"newcomers": n, "repos": repos, "return_days": RETURN_DAYS,
|
|
64
|
+
"by_answer": [{"bucket": BUCKETS[b], "newcomers": c, "returned": round(r, 3), "merged": round(m, 3)}
|
|
65
|
+
for b, c, r, m in by_answer],
|
|
66
|
+
"by_outcome": [{"outcome": o, "newcomers": c, "returned": round(r, 3)} for o, c, r in by_outcome]}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def repo_metrics(con: duckdb.DuckDBPyConnection, now: datetime) -> list[dict]:
|
|
70
|
+
"""The facts behind each repo's community score; a fact without enough PRs or newcomers is None."""
|
|
71
|
+
params = {"lo": now - timedelta(days=90), "hi": now - timedelta(days=1), "seen": now - timedelta(days=14),
|
|
72
|
+
"judged": now - timedelta(days=RETURN_DAYS + 30), "dep": now - timedelta(days=DEPENDENCY_DAYS)}
|
|
73
|
+
rows = con.execute(f"""
|
|
74
|
+
WITH {BURST},
|
|
75
|
+
recent AS (
|
|
76
|
+
SELECT p.repo_id, {ANSWER} AS answered_at, p.created_at FROM pr_class p
|
|
77
|
+
WHERE p.author_class = 'outsider' AND p.created_at >= $lo AND p.created_at < $hi AND NOT EXISTS (
|
|
78
|
+
SELECT 1 FROM burst b WHERE b.repo_id = p.repo_id AND b.author_login = p.author_login)),
|
|
79
|
+
answers AS (
|
|
80
|
+
SELECT repo_id, count(*) AS prs,
|
|
81
|
+
median(epoch(answered_at - created_at) / 3600) FILTER (WHERE answered_at IS NOT NULL) AS first_answer_hours,
|
|
82
|
+
avg((answered_at IS NOT NULL AND answered_at <= created_at + INTERVAL 1 DAY)::INT) AS answered_1d_share
|
|
83
|
+
FROM recent GROUP BY repo_id),
|
|
84
|
+
new AS ({NEWCOMERS}),
|
|
85
|
+
newcomers AS (
|
|
86
|
+
SELECT repo_id,
|
|
87
|
+
count(*) FILTER (WHERE created_at <= $seen) AS seen,
|
|
88
|
+
avg((answered_at IS NOT NULL AND answered_at <= created_at + INTERVAL 14 DAY)::INT)
|
|
89
|
+
FILTER (WHERE created_at <= $seen) AS newcomer_answered_share,
|
|
90
|
+
count(*) FILTER (WHERE created_at <= $judged) AS judged,
|
|
91
|
+
avg((next_at IS NOT NULL AND next_at <= created_at + INTERVAL {RETURN_DAYS} DAY)::INT)
|
|
92
|
+
FILTER (WHERE created_at <= $judged) AS newcomer_return_rate
|
|
93
|
+
FROM new GROUP BY repo_id),
|
|
94
|
+
-- Who carries the work, as the app measures it: people's merged PRs, the fewest people behind half of them.
|
|
95
|
+
by_person AS (
|
|
96
|
+
SELECT repo_id, author_login, count(*) AS n FROM pr_class
|
|
97
|
+
WHERE merged_at >= $dep AND author_role IN ('insider', 'outsider') GROUP BY repo_id, author_login),
|
|
98
|
+
running AS (
|
|
99
|
+
SELECT repo_id, n, sum(n) OVER (PARTITION BY repo_id) AS total,
|
|
100
|
+
sum(n) OVER (PARTITION BY repo_id ORDER BY n DESC, author_login ROWS UNBOUNDED PRECEDING) AS upto,
|
|
101
|
+
row_number() OVER (PARTITION BY repo_id ORDER BY n DESC, author_login) AS k
|
|
102
|
+
FROM by_person),
|
|
103
|
+
dependency AS (
|
|
104
|
+
SELECT repo_id, max(total) AS merged, max(n) / max(total) AS top_share,
|
|
105
|
+
min(k) FILTER (WHERE upto * 2 >= total) AS bus_factor
|
|
106
|
+
FROM running GROUP BY repo_id)
|
|
107
|
+
SELECT rm.repo_id, rm.language, rm.stars, a.prs, a.first_answer_hours, a.answered_1d_share,
|
|
108
|
+
n.seen, n.newcomer_answered_share, n.judged, n.newcomer_return_rate, d.merged, d.top_share, d.bus_factor
|
|
109
|
+
FROM repo_meta rm LEFT JOIN answers a USING (repo_id) LEFT JOIN newcomers n USING (repo_id)
|
|
110
|
+
LEFT JOIN dependency d USING (repo_id)
|
|
111
|
+
WHERE coalesce(a.prs, 0) >= {MIN_PRS} OR coalesce(n.seen, 0) >= {MIN_NEWCOMERS}
|
|
112
|
+
OR coalesce(d.merged, 0) >= {MIN_MERGED}""", params).fetchall()
|
|
113
|
+
out = []
|
|
114
|
+
for repo_id, lang, stars, prs, hours, share_1d, seen, answered, judged, returned, merged, top, bus in rows:
|
|
115
|
+
enough_prs, enough_seen, enough_judged = (prs or 0) >= MIN_PRS, (seen or 0) >= MIN_NEWCOMERS, (judged or 0) >= MIN_NEWCOMERS
|
|
116
|
+
enough_merged = (merged or 0) >= MIN_MERGED
|
|
117
|
+
out.append({"repo_id": repo_id, "language": lang, "stars": stars,
|
|
118
|
+
"first_answer_hours": round(hours, 2) if enough_prs and hours is not None else None,
|
|
119
|
+
"answered_1d_share": round(share_1d, 4) if enough_prs else None,
|
|
120
|
+
"newcomer_answered_share": round(answered, 4) if enough_seen else None,
|
|
121
|
+
"newcomer_return_rate": round(returned, 4) if enough_judged else None,
|
|
122
|
+
"bus_factor": int(bus) if enough_merged else None, "top_share": round(top, 4) if enough_merged else None})
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def community_benchmarks(con: duckdb.DuckDBPyConnection, now: datetime, top_languages: int = 25) -> dict:
|
|
127
|
+
"""Percentiles of the community facts by language and size (groups need MIN_GROUP repos), plus the newcomer table."""
|
|
128
|
+
repos = repo_metrics(con, now)
|
|
129
|
+
counts = {}
|
|
130
|
+
for r in repos:
|
|
131
|
+
counts[r["language"] or "Other"] = counts.get(r["language"] or "Other", 0) + 1
|
|
132
|
+
langs = {name for name, _ in sorted(counts.items(), key=lambda kv: -kv[1])[:top_languages]}
|
|
133
|
+
groups = {}
|
|
134
|
+
for r in repos:
|
|
135
|
+
lang = r["language"] if r["language"] in langs else "Other"
|
|
136
|
+
for key in (f"{lang}|{tier(r['stars'])}", f"{lang}|all", f"all|{tier(r['stars'])}", "all|all"):
|
|
137
|
+
groups.setdefault(key, []).append(r)
|
|
138
|
+
out = {}
|
|
139
|
+
for key, items in groups.items():
|
|
140
|
+
if len(items) < MIN_GROUP:
|
|
141
|
+
continue
|
|
142
|
+
entry = {"repos": len(items)}
|
|
143
|
+
for m in METRICS:
|
|
144
|
+
values = [float(r[m]) for r in items if r[m] is not None]
|
|
145
|
+
if len(values) >= MIN_GROUP:
|
|
146
|
+
entry[m] = {"repos": len(values), **quantiles(values)}
|
|
147
|
+
out[key] = entry
|
|
148
|
+
return {"version": 1, "built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "repos": len(repos), "definition": DEFINITION, "population": 'public GitHub repos in the daily crawl with enough outside pull requests and issues to measure', "gaps": 'no private or enterprise teams; GitHub only; a newcomer is someone new to that repo, not to open source',
|
|
149
|
+
"groups": out, "newcomers": newcomer_returns(con, now)}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Delivery benchmarks from the crawl: per repo the P75 of pickup, review and cycle to merge (the suite's nearest-rank
|
|
2
|
+
rule, so a repo's own Delivery page compares like with like), per group the spread with a bootstrap range on the median
|
|
3
|
+
and the share of repos inside LinearB's published bands, with the population and the gaps stated beside the numbers."""
|
|
4
|
+
import math
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
import duckdb
|
|
8
|
+
|
|
9
|
+
from mergescout.bench.ci import BOOTSTRAP_RESAMPLES as RESAMPLES
|
|
10
|
+
from mergescout.bench.ci import BOOTSTRAP_SEED as SEED
|
|
11
|
+
from mergescout.bench.ci import _pct, bootstrap_median, tier
|
|
12
|
+
|
|
13
|
+
MIN_PRS = 10 # PRs a repo needs in the window
|
|
14
|
+
MIN_VALUES = 5 # values a repo needs for one metric
|
|
15
|
+
MIN_GROUP = 50
|
|
16
|
+
METRICS = {"pickup_p75_hours": "pickup", "review_p75_hours": "review", "cycle_to_merge_p75_hours": "cycle"}
|
|
17
|
+
# LinearB's 2026 bands on P75 hours: elite below the first, good up to the second, fair up to the third.
|
|
18
|
+
LINEARB = {"pickup_p75_hours": (1, 4, 16), "review_p75_hours": (3, 14, 24)}
|
|
19
|
+
DEFINITION = ("per repo, PRs opened in the last 90 days by people (bots and AI agents left out), repos with 10+ such PRs: "
|
|
20
|
+
"the P75 (nearest rank) of hours from opening to the first review by a maintainer other than the author "
|
|
21
|
+
"(pickup), from that review to the merge (review), and from opening to the merge (cycle to merge); a repo "
|
|
22
|
+
"needs 5+ values for a metric. Groups by language and stars need 50+ repos.")
|
|
23
|
+
POPULATION = ("public GitHub repos in the daily crawl that had 10+ pull requests by people in the last 90 days; GitHub "
|
|
24
|
+
"only, any CI")
|
|
25
|
+
GAPS = ("no private or enterprise teams; no GitLab or Bitbucket; pickup counts from opening, not from leaving draft (the "
|
|
26
|
+
"crawl does not keep draft history), so repos that open drafts look slower than LinearB's own pickup; cycle to "
|
|
27
|
+
"merge has no coding or deploy time, so it is not LinearB's full cycle time")
|
|
28
|
+
BANDS_NOTE = ("LinearB's 2026 benchmark bands on P75 (pickup: elite under 1 h, good up to 4 h, fair up to 16 h; review: "
|
|
29
|
+
"elite under 3 h, good up to 14 h, fair up to 24 h); each share is the part of the group's repos inside that "
|
|
30
|
+
"band or a better one")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _p75(values: list[float]) -> float:
|
|
34
|
+
s = sorted(values)
|
|
35
|
+
return s[min(len(s) - 1, max(0, math.ceil(0.75 * len(s)) - 1))]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def per_repo(con: duckdb.DuckDBPyConnection, now: datetime, window_days: int = 90) -> list[dict]:
|
|
39
|
+
rows = con.execute("""
|
|
40
|
+
WITH w AS (
|
|
41
|
+
SELECT p.*, coalesce(p.first_human_review_at, p.first_review_at) AS review_at,
|
|
42
|
+
coalesce(p.first_human_review_by, p.first_review_by) AS review_by
|
|
43
|
+
FROM pr_class p WHERE p.created_at >= $since AND p.author_class IN ('insider', 'outsider'))
|
|
44
|
+
SELECT w.repo_id, any_value(rm.language), any_value(rm.stars), count(*),
|
|
45
|
+
list(epoch(w.review_at - w.created_at) / 3600) FILTER (
|
|
46
|
+
WHERE w.review_at IS NOT NULL AND w.review_by IS DISTINCT FROM w.author_login AND rv.kind = 'insider'),
|
|
47
|
+
list(epoch(w.merged_at - w.review_at) / 3600) FILTER (
|
|
48
|
+
WHERE w.merged_at IS NOT NULL AND w.review_at IS NOT NULL AND w.review_at <= w.merged_at
|
|
49
|
+
AND w.review_by IS DISTINCT FROM w.author_login AND rv.kind = 'insider'),
|
|
50
|
+
list(epoch(w.merged_at - w.created_at) / 3600) FILTER (WHERE w.merged_at IS NOT NULL)
|
|
51
|
+
FROM w LEFT JOIN people rv ON rv.repo_id = w.repo_id AND rv.login = w.review_by
|
|
52
|
+
LEFT JOIN repo_meta rm ON rm.repo_id = w.repo_id
|
|
53
|
+
GROUP BY w.repo_id HAVING count(*) >= $min_prs""",
|
|
54
|
+
{"since": now - timedelta(days=window_days), "min_prs": MIN_PRS}).fetchall()
|
|
55
|
+
out = []
|
|
56
|
+
for repo_id, language, stars, prs, pickup, review, cycle in rows:
|
|
57
|
+
rec = {"repo_id": repo_id, "language": language, "stars": stars, "prs": prs}
|
|
58
|
+
for metric, values in zip(METRICS, (pickup, review, cycle)):
|
|
59
|
+
values = [v for v in values or [] if v is not None and v >= 0]
|
|
60
|
+
rec[metric] = round(_p75(values), 4) if len(values) >= MIN_VALUES else None
|
|
61
|
+
out.append(rec)
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _entry(metric: str, values: list[float]) -> dict:
|
|
66
|
+
low, high = bootstrap_median(values)
|
|
67
|
+
entry = {"repos": len(values), **{f"p{int(q * 100)}": _pct(values, q) for q in (0.1, 0.25, 0.5, 0.75, 0.9)},
|
|
68
|
+
"p50_low": low, "p50_high": high}
|
|
69
|
+
if metric in LINEARB:
|
|
70
|
+
elite, good, fair = LINEARB[metric]
|
|
71
|
+
n = len(values)
|
|
72
|
+
entry["linearb"] = {"elite": round(sum(v < elite for v in values) / n, 4), "good": round(sum(v <= good for v in values) / n, 4),
|
|
73
|
+
"fair": round(sum(v <= fair for v in values) / n, 4)}
|
|
74
|
+
return entry
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def delivery_benchmarks(con: duckdb.DuckDBPyConnection, now: datetime, window_days: int = 90, top_languages: int = 25) -> dict:
|
|
78
|
+
"""Delivery percentiles of public repos by language and size; groups need 50+ repos with the metric."""
|
|
79
|
+
repos = per_repo(con, now, window_days)
|
|
80
|
+
counts = {}
|
|
81
|
+
for r in repos:
|
|
82
|
+
counts[r["language"] or "Other"] = counts.get(r["language"] or "Other", 0) + 1
|
|
83
|
+
langs = {name for name, _ in sorted(counts.items(), key=lambda kv: -kv[1])[:top_languages]}
|
|
84
|
+
groups = {}
|
|
85
|
+
for r in repos:
|
|
86
|
+
lang = r["language"] if r["language"] in langs else "Other"
|
|
87
|
+
for key in (f"{lang}|{tier(r['stars'])}", f"{lang}|all", f"all|{tier(r['stars'])}", "all|all"):
|
|
88
|
+
groups.setdefault(key, []).append(r)
|
|
89
|
+
out = {}
|
|
90
|
+
for key, items in groups.items():
|
|
91
|
+
if len(items) < MIN_GROUP:
|
|
92
|
+
continue
|
|
93
|
+
entry = {"repos": len(items)}
|
|
94
|
+
for metric in METRICS:
|
|
95
|
+
values = [r[metric] for r in items if r[metric] is not None]
|
|
96
|
+
if len(values) >= MIN_GROUP:
|
|
97
|
+
entry[metric] = _entry(metric, values)
|
|
98
|
+
out[key] = entry
|
|
99
|
+
return {"version": 1, "built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "window_days": window_days, "repos": len(repos),
|
|
100
|
+
"definition": DEFINITION, "population": POPULATION, "gaps": GAPS, "bands_note": BANDS_NOTE,
|
|
101
|
+
"method": {"percentile": "nearest rank", "bootstrap_resamples": RESAMPLES, "bootstrap_seed": SEED,
|
|
102
|
+
"range": "95% of the resampled medians"},
|
|
103
|
+
"groups": out}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Package -> source repo map from deps.dev (free, anonymous): the default version, release history, licence, advisories,
|
|
2
|
+
and how the repo link is known. A signed link is "verified"; a claimed one is checked against the repo's own manifest
|
|
3
|
+
(user decision D1), since anyone can point a package at a popular repo."""
|
|
4
|
+
import configparser
|
|
5
|
+
import http.client
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
import tomllib
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
from mergescout.github.graphql import GraphQLError, RateLimited
|
|
17
|
+
from mergescout.signals.bumps import _norm
|
|
18
|
+
|
|
19
|
+
API = "https://api.deps.dev"
|
|
20
|
+
SYSTEMS = {"npm": "NPM", "pypi": "PYPI"}
|
|
21
|
+
BATCH = 500
|
|
22
|
+
CLAIM_BATCH = 20
|
|
23
|
+
SAFE = re.compile(r"^[\w.-]+$")
|
|
24
|
+
SAFE_DIR = re.compile(r"^[\w.@+-]+(/[\w.@+-]+)*$")
|
|
25
|
+
SKIPPED = object()
|
|
26
|
+
# Pre-release markers in PyPI (1.0.0.dev6, 2.0rc1) and npm (6.0.0-beta.3) versions.
|
|
27
|
+
PRERELEASE = re.compile(r"(\d(a|b|rc|c)\d+|\.?dev\d*|\.?post\d*\.dev|-[0-9A-Za-z])", re.I)
|
|
28
|
+
# Dropped connections and resets come as several types; all of them are worth a retry.
|
|
29
|
+
NETWORK = (OSError, http.client.HTTPException)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def http_get_json(url: str, tries: int = 3) -> dict | None:
|
|
33
|
+
# deps.dev states no rate limit; a short back-off covers its rare 5xx answers. 404 means "not known".
|
|
34
|
+
for attempt in range(tries):
|
|
35
|
+
try:
|
|
36
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "mergescout"}), timeout=30) as r:
|
|
37
|
+
return json.loads(r.read())
|
|
38
|
+
except urllib.error.HTTPError as exc:
|
|
39
|
+
if exc.code == 404:
|
|
40
|
+
return None
|
|
41
|
+
if attempt == tries - 1:
|
|
42
|
+
raise
|
|
43
|
+
except NETWORK:
|
|
44
|
+
if attempt == tries - 1:
|
|
45
|
+
raise
|
|
46
|
+
time.sleep(2 ** attempt)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def http_post_json(url: str, body: dict, tries: int = 3) -> dict:
|
|
51
|
+
data = json.dumps(body).encode()
|
|
52
|
+
for attempt in range(tries):
|
|
53
|
+
try:
|
|
54
|
+
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json", "User-Agent": "mergescout"})
|
|
55
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
56
|
+
return json.loads(r.read())
|
|
57
|
+
except NETWORK:
|
|
58
|
+
if attempt == tries - 1:
|
|
59
|
+
raise
|
|
60
|
+
time.sleep(2 ** attempt)
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def lookup_packages(packages: list[tuple[str, str]], get_json=http_get_json, post_json=http_post_json,
|
|
65
|
+
now: datetime | None = None, workers: int = 8, budget_s: float | None = None) -> dict[tuple[str, str], dict]:
|
|
66
|
+
"""Per (ecosystem, name): default version, releases, repo and link kind, licences, advisories, deprecation.
|
|
67
|
+
deps.dev slows down after many calls (23 s vs 333 s for the same 300 packages), so a time budget stops early."""
|
|
68
|
+
wanted = list(dict.fromkeys((eco, name) for eco, name in packages if eco in SYSTEMS))
|
|
69
|
+
deadline = time.time() + budget_s if budget_s else None
|
|
70
|
+
|
|
71
|
+
def fetch(pair):
|
|
72
|
+
# Past the budget nothing more is asked; those packages keep their previous answer.
|
|
73
|
+
if deadline and time.time() > deadline:
|
|
74
|
+
return SKIPPED
|
|
75
|
+
try:
|
|
76
|
+
return get_json(f"{API}/v3/systems/{SYSTEMS[pair[0]]}/packages/{quote(pair[1], safe='')}")
|
|
77
|
+
except Exception: # noqa: BLE001
|
|
78
|
+
# One lost answer after retries only skips that package (it keeps its last answer).
|
|
79
|
+
return SKIPPED
|
|
80
|
+
with ThreadPoolExecutor(workers) as pool:
|
|
81
|
+
found = list(pool.map(fetch, wanted))
|
|
82
|
+
year_ago = ((now or datetime.now()) - timedelta(days=365)).strftime("%Y-%m-%d")
|
|
83
|
+
out, keys = {}, []
|
|
84
|
+
for (eco, name), pkg in zip(wanted, found):
|
|
85
|
+
if pkg is SKIPPED:
|
|
86
|
+
continue
|
|
87
|
+
info = {"ecosystem": eco, "package": name, "version": None, "repo": None, "link": None, "licenses": [],
|
|
88
|
+
"advisories": [], "deprecated": False, "deprecated_reason": None, "status": None, "releases_12m": 0,
|
|
89
|
+
"last_release": None, "first_release": None}
|
|
90
|
+
vs = [v for v in (pkg or {}).get("versions") or [] if v.get("publishedAt")]
|
|
91
|
+
if vs:
|
|
92
|
+
dates = sorted(v["publishedAt"][:10] for v in vs)
|
|
93
|
+
default = next((v for v in vs if v.get("isDefault")), max(vs, key=lambda v: v["publishedAt"]))
|
|
94
|
+
stable = [v for v in vs if not PRERELEASE.search(v["versionKey"]["version"])]
|
|
95
|
+
# A pre-release default (httpx 1.0.0.dev6) often lacks the repo link; the newest stable release speaks for the package.
|
|
96
|
+
if PRERELEASE.search(default["versionKey"]["version"]) and stable:
|
|
97
|
+
default = max(stable, key=lambda v: v["publishedAt"])
|
|
98
|
+
# Nightly and dev builds are not releases; a package with only pre-releases keeps them.
|
|
99
|
+
counted = sorted(v["publishedAt"][:10] for v in stable) or dates
|
|
100
|
+
info.update(version=default["versionKey"]["version"], first_release=dates[0], last_release=counted[-1],
|
|
101
|
+
releases_12m=sum(d >= year_ago for d in counted), release_rule=RELEASE_RULE)
|
|
102
|
+
keys.append((eco, name, info["version"]))
|
|
103
|
+
out[(eco, name)] = info
|
|
104
|
+
by_key = {(SYSTEMS[e], n, v): (e, n) for e, n, v in keys}
|
|
105
|
+
for i in range(0, len(keys), BATCH):
|
|
106
|
+
body = {"requests": [{"versionKey": {"system": SYSTEMS[e], "name": n, "version": v}} for e, n, v in keys[i:i + BATCH]]}
|
|
107
|
+
token = None
|
|
108
|
+
# deps.dev answers 100 versions per page and says where to go on; answers are matched by key, not position.
|
|
109
|
+
while True:
|
|
110
|
+
page = post_json(f"{API}/v3alpha/versionbatch", {**body, "pageToken": token} if token else body)
|
|
111
|
+
for resp in page.get("responses") or []:
|
|
112
|
+
k = ((resp or {}).get("request") or {}).get("versionKey") or {}
|
|
113
|
+
found = by_key.get((k.get("system"), k.get("name"), k.get("version")))
|
|
114
|
+
if found and (resp or {}).get("version"):
|
|
115
|
+
_fill(out[found], resp["version"])
|
|
116
|
+
token = page.get("nextPageToken")
|
|
117
|
+
if not token:
|
|
118
|
+
break
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _fill(info: dict, v: dict) -> None:
|
|
123
|
+
repos = [p for p in v.get("relatedProjects") or [] if p.get("relationType") == "SOURCE_REPO"]
|
|
124
|
+
# A signed build or publish attestation proves the link; plain metadata is only what the publisher typed.
|
|
125
|
+
repos.sort(key=lambda p: p.get("relationProvenance") == "UNVERIFIED_METADATA")
|
|
126
|
+
if repos:
|
|
127
|
+
project = repos[0]["projectKey"]["id"]
|
|
128
|
+
info["repo"] = project.removeprefix("github.com/")
|
|
129
|
+
info["link"] = "claimed" if repos[0].get("relationProvenance") == "UNVERIFIED_METADATA" else "verified"
|
|
130
|
+
info["licenses"] = v.get("licenses") or []
|
|
131
|
+
info["advisories"] = [a.get("id") for a in v.get("advisoryKeys") or [] if a.get("id")]
|
|
132
|
+
info["deprecated"] = bool(v.get("isDeprecated"))
|
|
133
|
+
# The registry's own words, cut short: shown as a quote, never acted on.
|
|
134
|
+
reason = " ".join((v.get("deprecatedReason") or "").split())[:160]
|
|
135
|
+
info["deprecated_reason"] = reason or None if info["deprecated"] else None
|
|
136
|
+
info["status"] = (v.get("projectStatus") or {}).get("status")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def manifest_name(path: str, text: str | None) -> str | None:
|
|
140
|
+
"""The package name a manifest declares: package.json, pyproject.toml ([project] or Poetry) or setup.cfg."""
|
|
141
|
+
if not text:
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
if path.endswith("package.json"):
|
|
145
|
+
return json.loads(text).get("name")
|
|
146
|
+
if path.endswith("pyproject.toml"):
|
|
147
|
+
doc = tomllib.loads(text)
|
|
148
|
+
return (doc.get("project") or {}).get("name") or ((doc.get("tool") or {}).get("poetry") or {}).get("name")
|
|
149
|
+
if path.endswith("setup.cfg"):
|
|
150
|
+
cfg = configparser.ConfigParser()
|
|
151
|
+
cfg.read_string(text)
|
|
152
|
+
return cfg.get("metadata", "name", fallback=None)
|
|
153
|
+
if path.endswith("setup.py"):
|
|
154
|
+
return _setup_py_name(text)
|
|
155
|
+
except (ValueError, tomllib.TOMLDecodeError, configparser.Error, AttributeError):
|
|
156
|
+
return None
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _setup_py_name(text: str) -> str | None:
|
|
161
|
+
# setup(name="x"), or setup(name=NAME) with NAME = "x" in the same file; anything computed is not guessed.
|
|
162
|
+
call = text[text.find("setup("):] if "setup(" in text else ""
|
|
163
|
+
if m := re.search(r"""\bname\s*=\s*['"]([^'"]+)['"]""", call):
|
|
164
|
+
return m.group(1)
|
|
165
|
+
if m := re.search(r"\bname\s*=\s*([A-Za-z_]\w*)\s*[,)\n]", call):
|
|
166
|
+
const = re.search(rf"""^\s*{m.group(1)}\s*=\s*['"]([^'"]+)['"]""", text, re.M)
|
|
167
|
+
return const.group(1) if const else None
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _npm_directory(name: str, get_json) -> str | None:
|
|
172
|
+
try:
|
|
173
|
+
d = get_json(f"https://registry.npmjs.org/{quote(name, safe='@')}/latest")
|
|
174
|
+
repo = d.get("repository") if isinstance(d, dict) else None
|
|
175
|
+
raw = repo.get("directory") if isinstance(repo, dict) else None
|
|
176
|
+
except Exception: # noqa: BLE001
|
|
177
|
+
return None
|
|
178
|
+
# Publishers write anything here (numbers, lists): only a plain string is used.
|
|
179
|
+
path = raw.strip().removeprefix("./").strip("/") if isinstance(raw, str) else ""
|
|
180
|
+
# The folder goes into the GitHub query, so only plain path names are used.
|
|
181
|
+
return path if SAFE_DIR.match(path) and ".." not in path.split("/") else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _candidates(eco: str, name: str, directory: str | None = None) -> list[str]:
|
|
185
|
+
# Where projects keep the manifest that publishes the package, from the layouts of 33 real unconfirmed links.
|
|
186
|
+
if eco == "pypi":
|
|
187
|
+
folders = ["", f"{name}/", f"{name.replace('-', '_')}/", f"libs/{name}/", f"packages/{name}/", "python/", f"{name}-python/"]
|
|
188
|
+
return list(dict.fromkeys([f"{d}{f}" for d in folders for f in ("pyproject.toml", "setup.py")] + ["setup.cfg"]))
|
|
189
|
+
short = name.split("/")[-1]
|
|
190
|
+
scoped = name.lstrip("@").replace("/", "-")
|
|
191
|
+
first = [f"{directory}/package.json"] if directory else []
|
|
192
|
+
return list(dict.fromkeys(first + ["package.json", f"packages/{short}/package.json", f"packages/{scoped}/package.json",
|
|
193
|
+
f"{short}/package.json", "npm/package.json"]))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def check_claims(infos: dict, gql, get_json=None) -> int:
|
|
197
|
+
"""Claimed GitHub links become "claimed_checked" when the repo's manifest names the package, else
|
|
198
|
+
"claimed_unconfirmed"; about 1 GraphQL point per 25 repos. With get_json, npm's repository.directory is looked in
|
|
199
|
+
first (monorepos). Returns how many were checked."""
|
|
200
|
+
todo = [i for i in infos.values() if i.get("link") == "claimed" and i.get("repo") and "/" in i["repo"]
|
|
201
|
+
and all(SAFE.match(part) for part in i["repo"].split("/", 1))]
|
|
202
|
+
for i in [x for x in infos.values() if x.get("link") == "claimed" and x not in todo]:
|
|
203
|
+
i["link"] = "claimed_unconfirmed"
|
|
204
|
+
npm = [i for i in todo if i["ecosystem"] == "npm"] if get_json is not None else []
|
|
205
|
+
with ThreadPoolExecutor(16) as pool:
|
|
206
|
+
for info, path in zip(npm, pool.map(lambda i: _npm_directory(i["package"], get_json), npm)):
|
|
207
|
+
info["directory"] = path
|
|
208
|
+
checked = 0
|
|
209
|
+
for start in range(0, len(todo), CLAIM_BATCH):
|
|
210
|
+
chunk = todo[start:start + CLAIM_BATCH]
|
|
211
|
+
lines, files = [], {}
|
|
212
|
+
for r, info in enumerate(chunk):
|
|
213
|
+
owner, name = info["repo"].split("/", 1)
|
|
214
|
+
lines.append(f' r{r}: repository(owner: "{owner}", name: "{name}") {{')
|
|
215
|
+
for f, path in enumerate(_candidates(info["ecosystem"], info["package"], info.get("directory"))):
|
|
216
|
+
files[(r, f)] = path
|
|
217
|
+
lines.append(f' f{f}: object(expression: "HEAD:{path}") {{ ... on Blob {{ text }} }}')
|
|
218
|
+
lines.append(" }")
|
|
219
|
+
query = "query {\n rateLimit { cost remaining resetAt }\n" + "\n".join(lines) + "\n}"
|
|
220
|
+
try:
|
|
221
|
+
data = gql.query(query, {}, partial=True).data
|
|
222
|
+
except (GraphQLError, RateLimited):
|
|
223
|
+
# The rest stay "claimed" (not yet checked) and are tried on the next run.
|
|
224
|
+
return checked
|
|
225
|
+
checked += len(chunk)
|
|
226
|
+
for r, info in enumerate(chunk):
|
|
227
|
+
repo = data.get(f"r{r}") or {}
|
|
228
|
+
names = {manifest_name(path, ((repo.get(f"f{f}") or {}).get("text"))) for (rr, f), path in files.items() if rr == r}
|
|
229
|
+
match = {_norm(info["ecosystem"], n) for n in names if n} & {_norm(info["ecosystem"], info["package"])}
|
|
230
|
+
info["link"] = "claimed_checked" if match else "claimed_unconfirmed"
|
|
231
|
+
return checked
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
MAX_AGE_DAYS = 7
|
|
235
|
+
# How releases are counted; answers from an older rule are looked up again (2: pre-releases no longer count).
|
|
236
|
+
RELEASE_RULE = 2
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def refresh_map(packages: list[tuple[str, str]], previous: dict, now: datetime, gql=None, budget_s: float = 600,
|
|
240
|
+
lookup=lookup_packages, check=check_claims, get_json=http_get_json) -> dict:
|
|
241
|
+
"""Yesterday's map with new and week-old packages looked up again (within the time budget) and their claimed
|
|
242
|
+
links checked when a GitHub token is at hand; a package skipped by the budget keeps its last answer."""
|
|
243
|
+
stamp = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
244
|
+
stale_before = (now - timedelta(days=MAX_AGE_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
245
|
+
keep, todo = {}, []
|
|
246
|
+
for eco, name in dict.fromkeys(packages):
|
|
247
|
+
old = previous.get(f"{eco}:{name}")
|
|
248
|
+
# Answers built on a pre-release (before stable versions were preferred) are asked again.
|
|
249
|
+
if (old and (old.get("fetched_at") or "") >= stale_before and not PRERELEASE.search(old.get("version") or "")
|
|
250
|
+
and old.get("release_rule", 1) >= RELEASE_RULE):
|
|
251
|
+
keep[f"{eco}:{name}"] = old
|
|
252
|
+
else:
|
|
253
|
+
todo.append((eco, name))
|
|
254
|
+
if old:
|
|
255
|
+
keep[f"{eco}:{name}"] = old
|
|
256
|
+
fresh = lookup(todo, now=now, budget_s=budget_s)
|
|
257
|
+
if gql is not None:
|
|
258
|
+
check(fresh, gql, get_json=get_json)
|
|
259
|
+
for (eco, name), info in fresh.items():
|
|
260
|
+
keep[f"{eco}:{name}"] = {**info, "fetched_at": stamp}
|
|
261
|
+
return keep
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""How a package's source repo treats outside work, quarter by quarter, from the crawl's PR history (12 months): outside
|
|
2
|
+
PRs merged, first maintainer answer, how much merging rests on one person; compared with repos of similar size."""
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
import duckdb
|
|
6
|
+
|
|
7
|
+
from mergescout.bench.ci import tier
|
|
8
|
+
|
|
9
|
+
MIN_DECIDED = 5
|
|
10
|
+
MIN_RESPONSES = 5
|
|
11
|
+
MIN_MERGES = 5
|
|
12
|
+
# Changes smaller than these are "steady": quarters of a few dozen PRs move around on their own.
|
|
13
|
+
RATE_STEP = 0.15
|
|
14
|
+
SPEED_RATIO = 1.5
|
|
15
|
+
# Automation that merges (Actions, merge queues, any login the crawl saw acting as a bot) is not one person merging.
|
|
16
|
+
BOT_MERGER = ("(lower({col}) LIKE '%[bot]' OR lower({col}) IN ('github-actions', 'github-merge-queue', 'mergify', 'kodiakhq', "
|
|
17
|
+
"'bors', 'dependabot', 'renovate') OR lower({col}) IN (SELECT lower(login) FROM people WHERE kind = 'bot'))")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _quarters(now: datetime, n: int = 4) -> list[tuple[int, int]]:
|
|
21
|
+
# The last n complete quarters, oldest first (the running quarter is partial).
|
|
22
|
+
year, q = now.year, (now.month - 1) // 3 + 1
|
|
23
|
+
out = []
|
|
24
|
+
for _ in range(n):
|
|
25
|
+
q -= 1
|
|
26
|
+
if q == 0:
|
|
27
|
+
year, q = year - 1, 4
|
|
28
|
+
out.append((year, q))
|
|
29
|
+
return out[::-1]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def repo_trends(con: duckdb.DuckDBPyConnection, repos: list[str], now: datetime) -> dict[str, dict]:
|
|
33
|
+
"""Per repo ("owner/name"): quarterly lines, their direction and the latest quarter's place among similar repos."""
|
|
34
|
+
quarters = _quarters(now)
|
|
35
|
+
lo = datetime(quarters[0][0], 3 * quarters[0][1] - 2, 1, tzinfo=now.tzinfo)
|
|
36
|
+
end_y, end_q = (now.year, (now.month - 1) // 3 + 1)
|
|
37
|
+
hi = datetime(end_y, 3 * end_q - 2, 1, tzinfo=now.tzinfo)
|
|
38
|
+
wanted = sorted({r.lower() for r in repos})
|
|
39
|
+
if not wanted:
|
|
40
|
+
return {}
|
|
41
|
+
rows = con.execute(f"""
|
|
42
|
+
WITH wanted AS (SELECT repo_id, lower(name) AS repo, stars FROM repo_meta WHERE lower(name) IN (SELECT unnest($repos))),
|
|
43
|
+
w AS (SELECT p.*, wanted.repo, year(timezone('UTC', p.created_at)) AS y, quarter(timezone('UTC', p.created_at)) AS q
|
|
44
|
+
FROM pr_class p JOIN wanted USING (repo_id) WHERE p.created_at >= $lo AND p.created_at < $hi)
|
|
45
|
+
SELECT w.repo, w.y, w.q,
|
|
46
|
+
count(*) FILTER (WHERE author_class = 'outsider') AS opened,
|
|
47
|
+
count(*) FILTER (WHERE author_class = 'outsider' AND state IN ('MERGED', 'CLOSED')) AS decided,
|
|
48
|
+
count(*) FILTER (WHERE author_class = 'outsider' AND merged_at IS NOT NULL) AS merged,
|
|
49
|
+
median(epoch(first_human_review_at - created_at) / 3600) FILTER (
|
|
50
|
+
WHERE author_class = 'outsider' AND first_human_review_at >= created_at AND pe.kind = 'insider') AS response,
|
|
51
|
+
count(*) FILTER (WHERE author_class = 'outsider' AND first_human_review_at >= created_at AND pe.kind = 'insider')
|
|
52
|
+
AS responses,
|
|
53
|
+
count(*) FILTER (WHERE merged_at IS NOT NULL AND merged_by IS NOT NULL
|
|
54
|
+
AND NOT {BOT_MERGER.format(col='merged_by')}) AS merges
|
|
55
|
+
FROM w LEFT JOIN people pe ON pe.repo_id = w.repo_id AND pe.login = w.first_human_review_by
|
|
56
|
+
GROUP BY ALL""", {"repos": wanted, "lo": lo, "hi": hi}).fetchall()
|
|
57
|
+
tops = {}
|
|
58
|
+
for repo, y, q, n in con.execute(f"""
|
|
59
|
+
SELECT lower(rm.name), year(timezone('UTC', p.created_at)), quarter(timezone('UTC', p.created_at)), count(*)
|
|
60
|
+
FROM pr_class p JOIN repo_meta rm USING (repo_id)
|
|
61
|
+
WHERE lower(rm.name) IN (SELECT unnest($repos)) AND p.merged_at IS NOT NULL AND p.merged_by IS NOT NULL
|
|
62
|
+
AND NOT {BOT_MERGER.format(col='p.merged_by')} AND p.created_at >= $lo AND p.created_at < $hi
|
|
63
|
+
GROUP BY 1, 2, 3, p.merged_by""", {"repos": wanted, "lo": lo, "hi": hi}).fetchall():
|
|
64
|
+
tops[(repo, y, q)] = max(tops.get((repo, y, q), 0), n)
|
|
65
|
+
stars = dict(con.execute("SELECT lower(name), stars FROM repo_meta WHERE lower(name) IN (SELECT unnest($repos))",
|
|
66
|
+
{"repos": wanted}).fetchall())
|
|
67
|
+
cells = {(r[0], r[1], r[2]): r for r in rows}
|
|
68
|
+
out = {}
|
|
69
|
+
for repo in wanted:
|
|
70
|
+
if repo not in stars:
|
|
71
|
+
continue
|
|
72
|
+
lines = []
|
|
73
|
+
for y, q in quarters:
|
|
74
|
+
r = cells.get((repo, y, q))
|
|
75
|
+
opened, decided, merged, response, responses, merges = r[3:] if r else (0, 0, 0, None, 0, 0)
|
|
76
|
+
lines.append({"quarter": f"{y}Q{q}", "outside_opened": opened, "outside_decided": decided,
|
|
77
|
+
"outside_merge_rate": round(merged / decided, 2) if decided >= MIN_DECIDED else None,
|
|
78
|
+
"first_response_hours": round(response, 1) if responses >= MIN_RESPONSES else None,
|
|
79
|
+
"merges": merges,
|
|
80
|
+
"top_merger_share": round(tops.get((repo, y, q), 0) / merges, 2) if merges >= MIN_MERGES else None})
|
|
81
|
+
out[repo] = {"stars": stars[repo], "quarters": lines, "direction": _direction(lines)}
|
|
82
|
+
_peers(out)
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _direction(lines: list[dict]) -> dict:
|
|
87
|
+
"""Earlier half against later half of the year, in plain words; None when either half has no value."""
|
|
88
|
+
def halves(key):
|
|
89
|
+
a = [x[key] for x in lines[:2] if x[key] is not None]
|
|
90
|
+
b = [x[key] for x in lines[2:] if x[key] is not None]
|
|
91
|
+
return (sum(a) / len(a), sum(b) / len(b)) if a and b else (None, None)
|
|
92
|
+
out = {}
|
|
93
|
+
a, b = halves("outside_merge_rate")
|
|
94
|
+
out["outside_merge_rate"] = None if a is None else "falling" if b - a <= -RATE_STEP else "rising" if b - a >= RATE_STEP else "steady"
|
|
95
|
+
a, b = halves("first_response_hours")
|
|
96
|
+
out["first_response_hours"] = None if a is None else ("slower" if b >= a * SPEED_RATIO and b - a > 24 else
|
|
97
|
+
"faster" if a >= b * SPEED_RATIO and a - b > 24 else "steady")
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _peers(out: dict) -> None:
|
|
102
|
+
# The latest quarter against mapped repos of the same stars tier (all of them when a tier has under 5).
|
|
103
|
+
latest = {repo: t["quarters"][-1] for repo, t in out.items()}
|
|
104
|
+
for repo, t in out.items():
|
|
105
|
+
same = [r for r in out if r != repo and tier(out[r]["stars"]) == tier(t["stars"])]
|
|
106
|
+
pool = same if len(same) >= 5 else [r for r in out if r != repo]
|
|
107
|
+
t["peers"] = {}
|
|
108
|
+
for key, better, words in (("outside_merge_rate", 1, ("merges outside PRs more than most similar repos",
|
|
109
|
+
"merges outside PRs less than most similar repos")),
|
|
110
|
+
("first_response_hours", -1, ("faster than most similar repos", "slower than most similar repos"))):
|
|
111
|
+
mine = latest[repo][key]
|
|
112
|
+
others = [latest[r][key] for r in pool if latest[r][key] is not None]
|
|
113
|
+
if mine is None or not others:
|
|
114
|
+
continue
|
|
115
|
+
ahead = sum(1 for v in others if (v - mine) * better > 0) / len(others)
|
|
116
|
+
place = words[1] if ahead >= 0.75 else words[0] if ahead <= 0.25 else "about typical for similar repos"
|
|
117
|
+
t["peers"][key] = {"place": place, "peers": len(others)}
|