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
mergescout/bench/ci.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import math
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from mergescout.bench.prior import beta_fit
|
|
11
|
+
|
|
12
|
+
API = "https://api.github.com"
|
|
13
|
+
FAIL = {"failure", "timed_out"}
|
|
14
|
+
OK = {"success", "neutral"}
|
|
15
|
+
MIN_RUNS = 5
|
|
16
|
+
PAGE = 50
|
|
17
|
+
MIN_GROUP = 20
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OutOfBudget(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _t(text: str | None) -> datetime | None:
|
|
25
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00")) if text else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _pct(values: list[float], q: float) -> float:
|
|
29
|
+
# Nearest-rank percentile: always a value that was really seen.
|
|
30
|
+
s = sorted(values)
|
|
31
|
+
return s[min(len(s) - 1, max(0, math.ceil(q * len(s)) - 1))]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _median(values: list[float]) -> float:
|
|
35
|
+
s, n = sorted(values), len(values)
|
|
36
|
+
return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def repo_stats(runs: list[dict], since: datetime | None = None, page_full: bool = False, durations: bool = False,
|
|
40
|
+
evidence: int = 0) -> dict | None:
|
|
41
|
+
"""CI of one repo per push: how long until all its checks finished, and how they ended."""
|
|
42
|
+
# GitHub-managed workflows (Dependabot, CodeQL default setup) are not the repo's own CI.
|
|
43
|
+
own = [r for r in runs if not (r.get("path") or "").startswith("dynamic/")
|
|
44
|
+
and r.get("created_at") and r.get("updated_at") and (since is None or (_t(r.get("created_at")) or since) >= since)]
|
|
45
|
+
pushes = {}
|
|
46
|
+
for r in own:
|
|
47
|
+
# A tag pushed later on the same commit is its own push, not one push lasting days.
|
|
48
|
+
pushes.setdefault((r.get("head_sha") or r.get("id"), r.get("head_branch")), []).append(r)
|
|
49
|
+
if page_full and pushes:
|
|
50
|
+
# The page ends somewhere inside its oldest push, which may have more runs on the next page.
|
|
51
|
+
pushes.pop(min(pushes, key=lambda k: min(r.get("created_at") or "" for r in pushes[k])))
|
|
52
|
+
done = []
|
|
53
|
+
for group in pushes.values():
|
|
54
|
+
if any(r.get("status") != "completed" for r in group):
|
|
55
|
+
continue
|
|
56
|
+
# Runs cancelled by a newer push (or skipped by a condition) did not give an answer.
|
|
57
|
+
group = [r for r in group if r.get("conclusion") not in ("cancelled", "skipped")]
|
|
58
|
+
if group:
|
|
59
|
+
done.append(group)
|
|
60
|
+
if len(done) < MIN_RUNS:
|
|
61
|
+
return None
|
|
62
|
+
rerun = lambda g: any((r.get("run_attempt") or 1) > 1 for r in g)
|
|
63
|
+
durations_s = []
|
|
64
|
+
for g in done:
|
|
65
|
+
start, end = min(_t(r.get("created_at")) for r in g), max(_t(r.get("updated_at")) for r in g)
|
|
66
|
+
# The wait a developer sees: queue plus run, up to the last check; re-runs would add human delay.
|
|
67
|
+
if not rerun(g) and start and end and end >= start:
|
|
68
|
+
durations_s.append((end - start).total_seconds())
|
|
69
|
+
if not durations_s:
|
|
70
|
+
return None
|
|
71
|
+
n = len(done)
|
|
72
|
+
# The pushes that weren't green on the first try, newest first: [sha, branch, the failed or re-run run ids].
|
|
73
|
+
missed = sorted((g for g in done if rerun(g) or not all(r.get("conclusion") in OK for r in g)),
|
|
74
|
+
key=lambda g: max(r.get("created_at") or "" for r in g), reverse=True)
|
|
75
|
+
proof = {"not_first_try": [[(g[0].get("head_sha") or "")[:12], g[0].get("head_branch"),
|
|
76
|
+
sorted(r["id"] for r in g if (r.get("run_attempt") or 1) > 1 or r.get("conclusion") not in OK)]
|
|
77
|
+
for g in missed[:evidence]], "not_first_try_total": len(missed)} if evidence else {}
|
|
78
|
+
return {**proof, "pushes": n, "median_s": round(_median(durations_s)), "p90_s": round(_pct(durations_s, 0.9)),
|
|
79
|
+
"fail_share": round(sum(any(r.get("conclusion") in FAIL for r in g) for g in done) / n, 4),
|
|
80
|
+
"first_try_share": round(sum(not rerun(g) and all(r.get("conclusion") in OK for r in g)
|
|
81
|
+
for g in done) / n, 4),
|
|
82
|
+
"rerun_share": round(sum(rerun(g) for g in done) / n, 4),
|
|
83
|
+
"workflows": len({r.get("path") for g in done for r in g}),
|
|
84
|
+
**({"durations": durations_s} if durations else {})}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def todays_share(names: list[str], now: datetime, slices: int = 28) -> list[str]:
|
|
88
|
+
# A stable hash splits the repos into 28 parts, one per day, so every repo is read once every four weeks.
|
|
89
|
+
day = now.toordinal() % slices
|
|
90
|
+
return [n for n in names if int(hashlib.sha1(n.lower().encode()).hexdigest(), 16) % slices == day]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TokenPool:
|
|
94
|
+
"""Several identities' REST limits (separate from the crawl's GraphQL points); stops before all run low."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, tokens: list[str], client: httpx.Client | None = None, reserve: int = 300,
|
|
97
|
+
sleep=time.sleep, clock=time.time, max_wait: float = 0):
|
|
98
|
+
seen = []
|
|
99
|
+
for t in tokens:
|
|
100
|
+
if t and t.strip() and t.strip() not in seen:
|
|
101
|
+
seen.append(t.strip())
|
|
102
|
+
self.tokens = {t: 5000 for t in seen}
|
|
103
|
+
self.resets = {t: 0.0 for t in seen}
|
|
104
|
+
self.client = client or httpx.Client(timeout=httpx.Timeout(60.0, connect=15.0), follow_redirects=True)
|
|
105
|
+
self.reserve, self.sleep, self.clock, self.lock, self.requests = reserve, sleep, clock, threading.Lock(), 0
|
|
106
|
+
self.max_wait, self.waited = max_wait, 0.0
|
|
107
|
+
|
|
108
|
+
def _pick(self) -> str:
|
|
109
|
+
with self.lock:
|
|
110
|
+
live = {t: left for t, left in self.tokens.items() if left > self.reserve}
|
|
111
|
+
if not self.tokens:
|
|
112
|
+
raise OutOfBudget("no token can read other repos' Actions runs")
|
|
113
|
+
if not live:
|
|
114
|
+
# Everyone is low: wait for the first hourly reset if allowed (other workers wait on the lock).
|
|
115
|
+
first = min(self.resets, key=self.resets.get)
|
|
116
|
+
wait = max(0.0, (self.resets[first] or self.clock() + 3600) - self.clock()) + 5
|
|
117
|
+
if self.waited + wait > self.max_wait:
|
|
118
|
+
raise OutOfBudget("every token is near its hourly REST limit")
|
|
119
|
+
self.waited += wait
|
|
120
|
+
self.sleep(wait)
|
|
121
|
+
self.tokens[first] = 5000
|
|
122
|
+
live = {first: 5000}
|
|
123
|
+
self.requests += 1
|
|
124
|
+
return max(live, key=live.get)
|
|
125
|
+
|
|
126
|
+
def get(self, path: str, params: dict) -> httpx.Response | None:
|
|
127
|
+
for attempt in range(3):
|
|
128
|
+
token = self._pick()
|
|
129
|
+
try:
|
|
130
|
+
resp = self.client.get(f"{API}{path}", params=params,
|
|
131
|
+
headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json",
|
|
132
|
+
"User-Agent": "mergescout-bench"})
|
|
133
|
+
except httpx.TransportError:
|
|
134
|
+
self.sleep(2 * (attempt + 1))
|
|
135
|
+
continue
|
|
136
|
+
left, reset = resp.headers.get("x-ratelimit-remaining"), resp.headers.get("x-ratelimit-reset")
|
|
137
|
+
with self.lock:
|
|
138
|
+
if left is not None:
|
|
139
|
+
self.tokens[token] = int(left)
|
|
140
|
+
if reset is not None:
|
|
141
|
+
self.resets[token] = float(reset)
|
|
142
|
+
if resp.status_code == 403 and "not accessible by integration" in resp.text.lower():
|
|
143
|
+
# App and Actions tokens may be limited to their own repos: drop the token, never the repo.
|
|
144
|
+
with self.lock:
|
|
145
|
+
self.tokens.pop(token, None)
|
|
146
|
+
self.resets.pop(token, None)
|
|
147
|
+
continue
|
|
148
|
+
if resp.status_code in (403, 429) and left == "0":
|
|
149
|
+
continue
|
|
150
|
+
if resp.status_code in (403, 429) and ("retry-after" in resp.headers or "secondary" in resp.text.lower()):
|
|
151
|
+
self.sleep(min(float(resp.headers.get("retry-after", 60)), 300))
|
|
152
|
+
continue
|
|
153
|
+
if resp.status_code >= 500:
|
|
154
|
+
self.sleep(3 * (attempt + 1))
|
|
155
|
+
continue
|
|
156
|
+
return resp
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _one(pool: TokenPool, repo: dict, since: datetime) -> dict | None:
|
|
161
|
+
# A date filter makes GitHub's query 3x slower and costlier; the newest runs are filtered here instead.
|
|
162
|
+
resp = pool.get(f"/repos/{repo['name']}/actions/runs",
|
|
163
|
+
{"event": "push", "per_page": PAGE, "exclude_pull_requests": "true"})
|
|
164
|
+
# 404: gone or renamed; 403: Actions turned off. Neither is a CI to compare.
|
|
165
|
+
if resp is None:
|
|
166
|
+
raise RuntimeError("GitHub gave no answer after retries")
|
|
167
|
+
if resp.status_code >= 400:
|
|
168
|
+
return None
|
|
169
|
+
runs = resp.json().get("workflow_runs") or []
|
|
170
|
+
stats = repo_stats(runs, since, page_full=len(runs) >= PAGE)
|
|
171
|
+
return {"repo": repo["name"], "language": repo.get("language"), "stars": repo.get("stars"), **stats} if stats else None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def fetch_all(pool: TokenPool, repos: list[dict], now: datetime, days: int = 30, workers: int = 10, log=None,
|
|
175
|
+
read=None) -> dict:
|
|
176
|
+
"""One sample per repo: push CI by default, or another reader (star history) given as read(pool, repo, now)."""
|
|
177
|
+
since = now - timedelta(days=days)
|
|
178
|
+
records, complete, day = [], True, now.strftime("%Y-%m-%d")
|
|
179
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
180
|
+
futures = [ex.submit(read, pool, r, now) if read else ex.submit(_one, pool, r, since) for r in repos]
|
|
181
|
+
for i, fut in enumerate(futures):
|
|
182
|
+
try:
|
|
183
|
+
rec = fut.result()
|
|
184
|
+
except Exception:
|
|
185
|
+
# One repo that can't be read (limits, a broken answer) never loses the rest of the day.
|
|
186
|
+
complete = False
|
|
187
|
+
continue
|
|
188
|
+
if rec:
|
|
189
|
+
records.append({**rec, "day": day})
|
|
190
|
+
if log and (i + 1) % 1000 == 0:
|
|
191
|
+
log(f"{i + 1}/{len(repos)} repos read, {len(records)} with enough data")
|
|
192
|
+
return {"records": records, "complete": complete, "requests": pool.requests}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def tier(stars: int | None) -> str:
|
|
196
|
+
stars = stars or 0
|
|
197
|
+
return "10k+" if stars >= 10_000 else "1k-10k" if stars >= 1000 else "100-1k"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
BOOTSTRAP_RESAMPLES, BOOTSTRAP_SEED = 400, 20261021
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def bootstrap_median(values: list[float], resamples: int = BOOTSTRAP_RESAMPLES, seed: int = BOOTSTRAP_SEED) -> tuple:
|
|
204
|
+
"""A 95% range for a group's median by resampling its repos; the same seed on every build, so the file recomputes."""
|
|
205
|
+
import random
|
|
206
|
+
rnd, n = random.Random(seed), len(values)
|
|
207
|
+
meds = sorted(_pct([values[rnd.randrange(n)] for _ in range(n)], 0.5) for _ in range(resamples))
|
|
208
|
+
return meds[int(0.025 * resamples)], meds[min(resamples - 1, int(0.975 * resamples))]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def quantiles(values: list[float]) -> dict:
|
|
212
|
+
"""P10-P90 of a group (nearest rank) and the 95% range of its median."""
|
|
213
|
+
low, high = bootstrap_median(values)
|
|
214
|
+
return {**{f"p{int(q * 100)}": _pct(values, q) for q in (0.1, 0.25, 0.5, 0.75, 0.9)}, "p50_low": low, "p50_high": high}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _summary(values: list[float]) -> dict:
|
|
218
|
+
return quantiles(values)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _prior(items: list[dict]) -> list[float] | None:
|
|
222
|
+
fit = beta_fit([(round(r["first_try_share"] * r["pushes"]), r["pushes"]) for r in items if r.get("pushes")])
|
|
223
|
+
return [round(x, 3) for x in fit] if fit else None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def aggregate(records: list[dict], now: datetime, top_languages: int = 25) -> dict:
|
|
227
|
+
"""Benchmarks by language and size; a group is published only with at least 20 repos."""
|
|
228
|
+
latest = {}
|
|
229
|
+
for r in sorted(records, key=lambda r: r.get("day") or ""):
|
|
230
|
+
latest[r["repo"].lower()] = r
|
|
231
|
+
recs = list(latest.values())
|
|
232
|
+
counts = {}
|
|
233
|
+
for r in recs:
|
|
234
|
+
counts[r.get("language") or "Other"] = counts.get(r.get("language") or "Other", 0) + 1
|
|
235
|
+
langs = {name for name, _ in sorted(counts.items(), key=lambda kv: -kv[1])[:top_languages]}
|
|
236
|
+
groups = {}
|
|
237
|
+
for r in recs:
|
|
238
|
+
lang = r.get("language") if r.get("language") in langs else "Other"
|
|
239
|
+
for key in (f"{lang}|{tier(r.get('stars'))}", f"{lang}|all", f"all|{tier(r.get('stars'))}", "all|all"):
|
|
240
|
+
groups.setdefault(key, []).append(r)
|
|
241
|
+
out = {}
|
|
242
|
+
for key, items in groups.items():
|
|
243
|
+
if len(items) < MIN_GROUP:
|
|
244
|
+
continue
|
|
245
|
+
out[key] = {"repos": len(items), "median_s": _summary([r["median_s"] for r in items]),
|
|
246
|
+
"p90_s": _summary([r["p90_s"] for r in items]),
|
|
247
|
+
"first_try_share": _summary([r["first_try_share"] for r in items]),
|
|
248
|
+
"fail_share": _summary([r["fail_share"] for r in items]),
|
|
249
|
+
"rerun_share": _summary([r["rerun_share"] for r in items]),
|
|
250
|
+
# The Beta prior of the group's first-try shares, so a repo with few pushes can be pulled toward it.
|
|
251
|
+
"first_try_prior": _prior(items)}
|
|
252
|
+
return {"version": 1, "built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "repos": len(recs),
|
|
253
|
+
"definition": "per push of the last 30 days (newest 50 push runs): time from push until all its checks finished, and how they ended; GitHub-managed workflows excluded",
|
|
254
|
+
"population": "public GitHub repos with 100+ stars that pushed in the last 30 days, a 28th of them read each day "
|
|
255
|
+
"(each about once in 4 weeks)",
|
|
256
|
+
"gaps": "GitHub Actions push runs only: no pull request runs, no other CI systems, no private or enterprise repos",
|
|
257
|
+
"groups": out}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""How often public repos ship: two REST requests per sampled repo (its deployments, its releases). Production deployments
|
|
2
|
+
count first, else stable releases (pre-releases and alpha/beta/rc tags left out); deployment days per week and per month
|
|
3
|
+
over 90 days with DevLake's counting (weeks with none count as zero, lower median) and DORA's 2023 frequency tiers."""
|
|
4
|
+
import re
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from mergescout.bench.ci import quantiles, tier
|
|
9
|
+
|
|
10
|
+
DAYS = 90
|
|
11
|
+
PAGE = 100
|
|
12
|
+
MIN_GROUP = 20
|
|
13
|
+
PRODUCTION = {"production", "prod", "live"}
|
|
14
|
+
PRE_TAG = re.compile(r"(?i)[-.](alpha|beta|rc|canary|next|dev|pre|preview|nightly|snapshot|insiders)([-.\d]|$)")
|
|
15
|
+
DEFINITION = ("per repo, the last 90 days: days with a successful-or-pending deployment to a production environment "
|
|
16
|
+
"(GitHub's Deployments API, an environment named production, prod or live, or starting with it), else days "
|
|
17
|
+
"with a published stable release (no pre-releases, no alpha/beta/rc tags); deployment days per week and per "
|
|
18
|
+
"month are DevLake's lower medians over the calendar (weeks with none count as zero); tiers are DORA 2023's "
|
|
19
|
+
"(elite 5+ days a week, high 1+ a week, medium 1+ a month, else low)")
|
|
20
|
+
POPULATION = "public GitHub repos with 100+ stars that pushed in the last 30 days, a slice of them read each day"
|
|
21
|
+
GAPS = ("deployments made outside GitHub (another repo, a hosting service without GitHub deployments, ArgoCD) are not seen, "
|
|
22
|
+
"nor deploy workflows without an environment; the newest 100 deployments and releases are read, so the busiest repos "
|
|
23
|
+
"are measured over the days those cover; deployment state is not read, so a failed deployment can count")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _first_word(env: str | None) -> str:
|
|
27
|
+
return re.split(r"[\s\-–_/:]+", (env or "").strip().lower(), maxsplit=1)[0]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _lower_median(values: list[int]) -> int:
|
|
31
|
+
# DevLake: the largest value whose percent rank is at most 0.5 (ties share the lowest rank).
|
|
32
|
+
v = sorted(values)
|
|
33
|
+
if not v:
|
|
34
|
+
return 0
|
|
35
|
+
if len(v) == 1:
|
|
36
|
+
return v[0]
|
|
37
|
+
return max(x for x in v if v.index(x) / (len(v) - 1) <= 0.5)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def frequency(days: set[str], now: datetime, start: datetime) -> tuple[int, int, str]:
|
|
41
|
+
"""(deployment days per week, per month, DORA 2023 tier) over the calendar days after `start` up to today."""
|
|
42
|
+
weeks, months = defaultdict(int), defaultdict(int)
|
|
43
|
+
k = 0
|
|
44
|
+
while now - timedelta(days=k) > start:
|
|
45
|
+
day = (now - timedelta(days=k)).date()
|
|
46
|
+
hit = day.isoformat() in days
|
|
47
|
+
weeks[day - timedelta(days=day.weekday())] += hit
|
|
48
|
+
months[day.replace(day=1)] += hit
|
|
49
|
+
k += 1
|
|
50
|
+
per_week, per_month = _lower_median(list(weeks.values())), _lower_median(list(months.values()))
|
|
51
|
+
level = "elite" if per_week >= 5 else "high" if per_week >= 1 else "medium" if per_month >= 1 else "low"
|
|
52
|
+
return per_week, per_month, level
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _get(pool, path: str):
|
|
56
|
+
resp = pool.get(path, {"per_page": PAGE})
|
|
57
|
+
if resp is None:
|
|
58
|
+
raise RuntimeError("GitHub gave no answer after retries")
|
|
59
|
+
return resp.json() if resp.status_code < 400 else []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def repo_deploys(pool, repo: dict, now: datetime, days: int = DAYS) -> dict:
|
|
63
|
+
"""One repo: where its shipping is seen, on how many days, and its frequency (two requests)."""
|
|
64
|
+
since = now - timedelta(days=days)
|
|
65
|
+
stamp = since.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
66
|
+
deployments = _get(pool, f"/repos/{repo['name']}/deployments")
|
|
67
|
+
releases = _get(pool, f"/repos/{repo['name']}/releases")
|
|
68
|
+
found = [d["created_at"] for d in deployments if isinstance(d, dict) and _first_word(d.get("environment")) in PRODUCTION
|
|
69
|
+
and (d.get("created_at") or "") >= stamp]
|
|
70
|
+
source, page = "deployments api", deployments
|
|
71
|
+
if not found:
|
|
72
|
+
found = [r["published_at"] for r in releases if isinstance(r, dict) and r.get("published_at") and not r.get("draft")
|
|
73
|
+
and not r.get("prerelease") and not PRE_TAG.search(r.get("tag_name") or "") and r["published_at"] >= stamp]
|
|
74
|
+
source, page = ("releases", releases) if found else (None, [])
|
|
75
|
+
start = since
|
|
76
|
+
if len(page) >= PAGE and found:
|
|
77
|
+
# A full page that ends inside the window: measure only the days it covers.
|
|
78
|
+
start = max(since, datetime.fromisoformat(min(found).replace("Z", "+00:00")) - timedelta(days=1))
|
|
79
|
+
per_week, per_month, level = frequency({t[:10] for t in found}, now, start) if found else (0, 0, None)
|
|
80
|
+
return {"repo": repo["name"], "language": repo.get("language"), "stars": repo.get("stars"), "source": source,
|
|
81
|
+
"deploy_days": len({t[:10] for t in found}), "days_per_week": per_week, "days_per_month": per_month, "tier": level,
|
|
82
|
+
"capped": len(page) >= PAGE, "requests": 2, "day": now.strftime("%Y-%m-%d")}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def aggregate_deploys(records: list[dict], now: datetime, top_languages: int = 25) -> dict:
|
|
86
|
+
"""Groups by language and stars (20+ repos): where shipping is seen, and how often among repos where it is."""
|
|
87
|
+
latest = {}
|
|
88
|
+
for r in sorted(records, key=lambda r: r.get("day") or ""):
|
|
89
|
+
latest[r["repo"].lower()] = r
|
|
90
|
+
recs = list(latest.values())
|
|
91
|
+
counts = Counter(r.get("language") or "Other" for r in recs)
|
|
92
|
+
langs = {name for name, _ in counts.most_common(top_languages)}
|
|
93
|
+
groups = defaultdict(list)
|
|
94
|
+
for r in recs:
|
|
95
|
+
lang = r.get("language") if r.get("language") in langs else "Other"
|
|
96
|
+
for key in (f"{lang}|{tier(r.get('stars'))}", f"{lang}|all", f"all|{tier(r.get('stars'))}", "all|all"):
|
|
97
|
+
groups[key].append(r)
|
|
98
|
+
out = {}
|
|
99
|
+
for key, items in groups.items():
|
|
100
|
+
if len(items) < MIN_GROUP:
|
|
101
|
+
continue
|
|
102
|
+
n = len(items)
|
|
103
|
+
shipping = [r for r in items if r.get("source")]
|
|
104
|
+
entry = {"repos": n, "sources": {s: round(sum(r.get("source") == s for r in items) / n, 4)
|
|
105
|
+
for s in ("deployments api", "releases")}}
|
|
106
|
+
entry["sources"]["none"] = round(1 - sum(entry["sources"].values()), 4)
|
|
107
|
+
if len(shipping) >= MIN_GROUP:
|
|
108
|
+
entry["days_per_week"] = {"repos": len(shipping), **quantiles([r["days_per_week"] for r in shipping])}
|
|
109
|
+
entry["days_per_month"] = {"repos": len(shipping), **quantiles([r["days_per_month"] for r in shipping])}
|
|
110
|
+
entry["tiers"] = {t: round(sum(r.get("tier") == t for r in shipping) / len(shipping), 4)
|
|
111
|
+
for t in ("elite", "high", "medium", "low")}
|
|
112
|
+
out[key] = entry
|
|
113
|
+
return {"version": 1, "built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "repos": len(recs),
|
|
114
|
+
"requests": sum(r.get("requests") or 0 for r in recs), "definition": DEFINITION, "population": POPULATION,
|
|
115
|
+
"gaps": GAPS, "groups": out}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Like-with-like comparisons for the benchmarks: a share compared within matched groups, so a crawl-wide gap is not
|
|
2
|
+
just a difference in which repos, months or sizes each kind of pull request comes from."""
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def mh_risk_difference(groups: list[tuple[int, int, int, int]]) -> tuple[float | None, float | None]:
|
|
7
|
+
"""(difference, standard error) of a share between two kinds of item compared within matched groups: each group is
|
|
8
|
+
(hits, items) of the first kind and (hits, items) of the second. Mantel-Haenszel weights; Sato's variance, which stays
|
|
9
|
+
right when most groups hold one or two items (a plain per-group variance falls to zero there)."""
|
|
10
|
+
w_sum = num = p_sum = q_sum = 0.0
|
|
11
|
+
for a, n1, c, n0 in groups:
|
|
12
|
+
total = n1 + n0
|
|
13
|
+
if not n1 or not n0:
|
|
14
|
+
continue
|
|
15
|
+
w_sum += n1 * n0 / total
|
|
16
|
+
num += (a * n0 - c * n1) / total
|
|
17
|
+
p_sum += (n1 * n1 * c - n0 * n0 * a + n1 * n0 * (n0 - n1) / 2) / (total * total)
|
|
18
|
+
q_sum += (a * (n0 - c) + c * (n1 - a)) / (2 * total)
|
|
19
|
+
if not w_sum:
|
|
20
|
+
return None, None
|
|
21
|
+
rd = num / w_sum
|
|
22
|
+
return rd, math.sqrt(max(0.0, rd * p_sum + q_sum)) / w_sum
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Peer priors for the benchmarks: the Beta(a, b) that best explains many repos' (hits, tries) pairs, so a small repo's
|
|
2
|
+
share can be pulled toward repos like it instead of shown raw."""
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _golden(f, lo: float, hi: float, steps: int = 60) -> float:
|
|
7
|
+
# The x in [lo, hi] where f is largest, for f with one peak.
|
|
8
|
+
g = (math.sqrt(5) - 1) / 2
|
|
9
|
+
x1, x2 = hi - g * (hi - lo), lo + g * (hi - lo)
|
|
10
|
+
f1, f2 = f(x1), f(x2)
|
|
11
|
+
for _ in range(steps):
|
|
12
|
+
if f1 < f2:
|
|
13
|
+
lo, x1, f1 = x1, x2, f2
|
|
14
|
+
x2 = lo + g * (hi - lo)
|
|
15
|
+
f2 = f(x2)
|
|
16
|
+
else:
|
|
17
|
+
hi, x2, f2 = x2, x1, f1
|
|
18
|
+
x1 = hi - g * (hi - lo)
|
|
19
|
+
f1 = f(x1)
|
|
20
|
+
return (lo + hi) / 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def beta_fit(pairs: list[tuple[int, int]]) -> tuple[float, float] | None:
|
|
24
|
+
"""The Beta(a, b) prior that best explains many (hits, tries) pairs (beta-binomial maximum likelihood)."""
|
|
25
|
+
from collections import Counter
|
|
26
|
+
counts = Counter((min(k, n), n) for k, n in pairs if n > 0)
|
|
27
|
+
if not counts:
|
|
28
|
+
return None
|
|
29
|
+
lbeta = lambda x, y: math.lgamma(x) + math.lgamma(y) - math.lgamma(x + y) # noqa: E731
|
|
30
|
+
|
|
31
|
+
def loglik(mu: float, log_s: float) -> float:
|
|
32
|
+
s = math.exp(log_s)
|
|
33
|
+
a, b = mu * s, (1 - mu) * s
|
|
34
|
+
return sum(c * (lbeta(k + a, n - k + b) - lbeta(a, b)) for (k, n), c in counts.items())
|
|
35
|
+
total_k, total_n = sum(k * c for (k, n), c in counts.items()), sum(n * c for (k, n), c in counts.items())
|
|
36
|
+
mu, log_s = min(0.999, max(0.001, total_k / total_n)), math.log(10)
|
|
37
|
+
# Alternate between the mean and the strength (a + b); a few rounds settle both.
|
|
38
|
+
for _ in range(6):
|
|
39
|
+
log_s = _golden(lambda v: loglik(mu, v), math.log(0.05), math.log(1e4))
|
|
40
|
+
mu = _golden(lambda v: loglik(v, log_s), 0.001, 0.999)
|
|
41
|
+
s = math.exp(log_s)
|
|
42
|
+
return mu * s, (1 - mu) * s
|