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,75 @@
|
|
|
1
|
+
"""GitLab merge requests and deployments in the shapes Delivery reads for GitHub, so DORA and the review phases come
|
|
2
|
+
out the same way (Middleware's GitLab support, without a server):
|
|
3
|
+
|
|
4
|
+
stackvitals delivery group/project --gitlab [--days 90]
|
|
5
|
+
|
|
6
|
+
Each merged merge request's notes tell when someone else first answered and when it was approved; its merge (or
|
|
7
|
+
squash) commit links it to the production deployment that shipped it. Public projects need no token."""
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
|
|
10
|
+
from stackvitals.ci.api import APIError
|
|
11
|
+
from stackvitals.ci.gitlab import GitLab
|
|
12
|
+
|
|
13
|
+
APPROVED = ("approved this merge request",)
|
|
14
|
+
READY = ("marked this merge request as ready", "marked as ready")
|
|
15
|
+
DRAFT = ("marked this merge request as draft", "marked as a draft", "marked this merge request as a draft")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _iso(s: str | None) -> str | None:
|
|
19
|
+
if not s:
|
|
20
|
+
return None
|
|
21
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00")).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def mr_record(mr: dict, notes: list[dict]) -> dict:
|
|
25
|
+
"""One merge request as Team Flow's pull request record."""
|
|
26
|
+
author = (mr.get("author") or {}).get("username")
|
|
27
|
+
ev, talk = [], []
|
|
28
|
+
for n in sorted(notes or [], key=lambda n: n.get("created_at") or ""):
|
|
29
|
+
who, at, body = (n.get("author") or {}).get("username"), _iso(n.get("created_at")), (n.get("body") or "").lower()
|
|
30
|
+
if n.get("system"):
|
|
31
|
+
if any(body.startswith(x) for x in APPROVED):
|
|
32
|
+
ev.append(["review", at, who, "APPROVED", "MEMBER", "User", False])
|
|
33
|
+
elif any(body.startswith(x) for x in READY):
|
|
34
|
+
ev.append(["ready", at])
|
|
35
|
+
elif any(body.startswith(x) for x in DRAFT):
|
|
36
|
+
ev.append(["draft", at])
|
|
37
|
+
elif who and who != author:
|
|
38
|
+
talk.append([at, who, "MEMBER", "User", False])
|
|
39
|
+
if mr.get("merged_at"):
|
|
40
|
+
ev.append(["merged", _iso(mr["merged_at"]), (mr.get("merged_by") or {}).get("username")])
|
|
41
|
+
return {"t": "pr", "n": mr["iid"], "ti": (mr.get("title") or "")[:200], "draft": bool(mr.get("draft")),
|
|
42
|
+
"st": "MERGED" if mr.get("merged_at") else (mr.get("state") or "").upper(), "cr": _iso(mr.get("created_at")),
|
|
43
|
+
"up": _iso(mr.get("updated_at")), "mg": _iso(mr.get("merged_at")), "cl": _iso(mr.get("closed_at") or mr.get("merged_at")),
|
|
44
|
+
"au": author, "at": "Bot" if (mr.get("author") or {}).get("bot") else "User", "as": "MEMBER",
|
|
45
|
+
"base": mr.get("target_branch"), "head": mr.get("source_branch"),
|
|
46
|
+
"mc": mr.get("squash_commit_sha") or mr.get("merge_commit_sha") or mr.get("sha"),
|
|
47
|
+
"lb": list(mr.get("labels") or []), "paths": [], "rq": [], "ev": sorted(ev, key=lambda e: e[1] or ""),
|
|
48
|
+
"cm": talk, "fa": None, "nc": None}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def deployment_record(d: dict) -> dict:
|
|
52
|
+
"""One GitLab deployment as the repo query's deployment."""
|
|
53
|
+
state = {"success": "SUCCESS", "failed": "FAILURE", "canceled": None}.get(d.get("status"))
|
|
54
|
+
return {"env": (d.get("environment") or {}).get("name"), "at": _iso(d.get("created_at")), "sha": d.get("sha"),
|
|
55
|
+
"state": state, "done": _iso(d.get("finished_at") or d.get("updated_at")) if state == "SUCCESS" else None}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def collect(lab: GitLab, now: datetime, days: int = 90, max_mrs: int = 400) -> tuple[dict, dict]:
|
|
59
|
+
"""(flow, repo) for Delivery: merged merge requests of the window with their notes, and the deployments."""
|
|
60
|
+
since = (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
61
|
+
info = lab.get("") or {}
|
|
62
|
+
closed = []
|
|
63
|
+
for mr in lab.pages("/merge_requests", {"state": "merged", "updated_after": since, "order_by": "updated_at"}):
|
|
64
|
+
if len(closed) >= max_mrs:
|
|
65
|
+
break
|
|
66
|
+
try:
|
|
67
|
+
notes = list(lab.pages(f"/merge_requests/{mr['iid']}/notes", {"sort": "asc"}, limit=3))
|
|
68
|
+
except APIError:
|
|
69
|
+
# gitlab.com shows notes only with a token, even on public projects: phases then end at the merge.
|
|
70
|
+
notes = []
|
|
71
|
+
closed.append(mr_record(mr, notes))
|
|
72
|
+
deps = [deployment_record(d) for d in lab.pages("/deployments", {"updated_after": since, "order_by": "updated_at"}, limit=20)]
|
|
73
|
+
repo = {"default_branch": info.get("default_branch"), "private": info.get("visibility") != "public",
|
|
74
|
+
"deployments": [d for d in deps if d["state"]], "releases": []}
|
|
75
|
+
return {"closed": closed, "open": []}, repo
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
from stackvitals import codeowners
|
|
6
|
+
from stackvitals.core.actors import People
|
|
7
|
+
from stackvitals.flow.analyze import _human_reviews, _iso, _median, _secs, _t, is_bot
|
|
8
|
+
|
|
9
|
+
HALF_LIFE_DAYS = 180
|
|
10
|
+
SINGLE_OWNER = 0.8
|
|
11
|
+
LOAD_SCALE = 4
|
|
12
|
+
# CODEOWNERS only nudges: on home-assistant a full boost cut hits from 46% to 41%, a quarter cost nothing.
|
|
13
|
+
OWNER_BOOST = 0.25
|
|
14
|
+
# Who reviewed this author's earlier PRs counts too: with it, a real reviewer was in the top 3 for 79.3% of 555 held-out
|
|
15
|
+
# PRs, against 77.5% without and 73.0% for the busiest reviewers (bench/accuracy/team).
|
|
16
|
+
PAIR_WEIGHT = 1.0
|
|
17
|
+
REVERT_WINDOW = timedelta(days=7)
|
|
18
|
+
# Lock files change with every dependency bump; they say nothing about who knows the code.
|
|
19
|
+
LOCK_FILES = {"Cargo.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock", "uv.lock", "go.sum",
|
|
20
|
+
"Gemfile.lock", "composer.lock", "Pipfile.lock", "bun.lockb", "flake.lock", "mix.lock", "pubspec.lock"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _areas(paths: list[str] | None) -> set[str]:
|
|
24
|
+
return {area(path) for path in paths or [] if path.rsplit("/", 1)[-1] not in LOCK_FILES}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def area(path: str) -> str:
|
|
28
|
+
"""A path's area: its first two folders ("src/db"), its folder, or "(root)"."""
|
|
29
|
+
parts = path.split("/")
|
|
30
|
+
if len(parts) == 1:
|
|
31
|
+
return "(root)"
|
|
32
|
+
return parts[0] if len(parts) == 2 else "/".join(parts[:2])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Expertise(dict):
|
|
36
|
+
"""Area -> {login: weight}, plus how many PRs each person wrote and reviewed there."""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.authored = defaultdict(lambda: defaultdict(int))
|
|
41
|
+
self.reviewed = defaultdict(lambda: defaultdict(int))
|
|
42
|
+
# People who can review here: seen as owner, member or collaborator.
|
|
43
|
+
self.insiders = set()
|
|
44
|
+
# Author -> {reviewer: merged PRs of theirs that person reviewed}.
|
|
45
|
+
self.pairs = defaultdict(lambda: defaultdict(int))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _merged_humans(prs: list[dict], now: datetime, days: int = 365, before: str | None = None) -> list[dict]:
|
|
49
|
+
since = _iso(now - timedelta(days=days))
|
|
50
|
+
return [p for p in prs if p.get("mg") and p["mg"] >= since and not is_bot(p) and (before is None or p["mg"] < before)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def expertise(prs: list[dict], now: datetime, before: str | None = None, people: People | None = None) -> Expertise:
|
|
54
|
+
"""Who knows each area: authors and maintainer reviewers of merged PRs, recent work counting more."""
|
|
55
|
+
ex = Expertise()
|
|
56
|
+
if people is None:
|
|
57
|
+
people = People(prs)
|
|
58
|
+
for p in _merged_humans(prs, now, before=before):
|
|
59
|
+
age = (now - _t(p["mg"])).total_seconds() / 86400
|
|
60
|
+
w = 0.5 ** (age / HALF_LIFE_DAYS)
|
|
61
|
+
reviewers = {e[2] for e in _human_reviews(p) if people.is_insider(e[2])}
|
|
62
|
+
ex.insiders |= reviewers | ({p["au"]} if people.is_insider(p.get("au")) else set())
|
|
63
|
+
for r in reviewers:
|
|
64
|
+
ex.pairs[p["au"]][r] += 1
|
|
65
|
+
for a in _areas(p.get("paths")):
|
|
66
|
+
known = ex.setdefault(a, {})
|
|
67
|
+
known[p["au"]] = known.get(p["au"], 0.0) + w
|
|
68
|
+
ex.authored[a][p["au"]] += 1
|
|
69
|
+
for r in reviewers:
|
|
70
|
+
known[r] = known.get(r, 0.0) + 0.5 * w
|
|
71
|
+
ex.reviewed[a][r] += 1
|
|
72
|
+
return ex
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@lru_cache(maxsize=16)
|
|
76
|
+
def _rules(text: str | None) -> list:
|
|
77
|
+
return codeowners.parse(text)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def codeowners_for(path: str, text: str | None) -> list[str]:
|
|
81
|
+
"""Owners of a path from CODEOWNERS text, matched like GitHub (shared matcher), without the "@"."""
|
|
82
|
+
return [w.lstrip("@") for w in codeowners.owners_for(path, _rules(text))]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def suggest_reviewers(pr: dict, ex: Expertise, load: dict[str, int], owners: dict[str, list[str]],
|
|
86
|
+
speed: dict[str, float] | None = None, k: int = 3) -> list[dict]:
|
|
87
|
+
"""Up to k people for a PR: who knows its areas, plus code owners, eased off by how busy they are.
|
|
88
|
+
Answer speed only counts in private views (speed given)."""
|
|
89
|
+
areas = _areas(pr.get("paths"))
|
|
90
|
+
score = defaultdict(float)
|
|
91
|
+
for a in areas:
|
|
92
|
+
for who, w in ex.get(a, {}).items():
|
|
93
|
+
score[who] += w
|
|
94
|
+
top = max(score.values(), default=1.0)
|
|
95
|
+
owned = {o for path in pr.get("paths") or [] for o in owners.get(path, [])}
|
|
96
|
+
for o in owned:
|
|
97
|
+
score[o] += OWNER_BOOST * top
|
|
98
|
+
paired = {r: n for r, n in ex.pairs.get(pr.get("au"), {}).items() if r != pr.get("au")}
|
|
99
|
+
for r, n in paired.items():
|
|
100
|
+
score[r] += PAIR_WEIGHT * top * n / sum(paired.values())
|
|
101
|
+
out = []
|
|
102
|
+
for who, s in score.items():
|
|
103
|
+
if who == pr.get("au") or (who or "").endswith("[bot]") or (who not in ex.insiders and who not in owned):
|
|
104
|
+
continue
|
|
105
|
+
busy = load.get(who, 0)
|
|
106
|
+
s = s / (1 + busy / LOAD_SCALE)
|
|
107
|
+
if speed and who in speed and speed[who] is not None:
|
|
108
|
+
s = s / (1 + speed[who] / (2 * 86400))
|
|
109
|
+
wrote = sum(ex.authored[a].get(who, 0) for a in areas)
|
|
110
|
+
reviewed = sum(ex.reviewed[a].get(who, 0) for a in areas)
|
|
111
|
+
why = []
|
|
112
|
+
if wrote or reviewed:
|
|
113
|
+
why.append(f"wrote {wrote} and reviewed {reviewed} merged PRs in {', '.join(sorted(areas))}")
|
|
114
|
+
if who in owned:
|
|
115
|
+
why.append("code owner")
|
|
116
|
+
if paired.get(who):
|
|
117
|
+
why.append(f"reviewed {paired[who]} of this author's merged PRs")
|
|
118
|
+
if busy:
|
|
119
|
+
why.append(f"{busy} open review requests")
|
|
120
|
+
if speed and speed.get(who) is not None:
|
|
121
|
+
hours = speed[who] / 3600
|
|
122
|
+
why.append(f"usually answers in {hours:.0f}h" if hours >= 1 else "usually answers in under an hour")
|
|
123
|
+
out.append({"login": who, "score": round(s, 3), "why": "; ".join(why)})
|
|
124
|
+
return sorted(out, key=lambda x: -x["score"])[:k]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def knowledge_map(prs: list[dict], now: datetime) -> dict:
|
|
128
|
+
"""Per area: its main author and their share, one-person areas, owners gone quiet, and recent hotspots."""
|
|
129
|
+
merged = _merged_humans(prs, now)
|
|
130
|
+
ex = expertise(prs, now)
|
|
131
|
+
last_seen = {}
|
|
132
|
+
for p in merged:
|
|
133
|
+
last_seen[p["au"]] = max(last_seen.get(p["au"], ""), p["mg"])
|
|
134
|
+
recent = _iso(now - timedelta(days=90))
|
|
135
|
+
# Busy = how many merged PRs touched the area lately (line counts per file aren't collected).
|
|
136
|
+
busy, counts = defaultdict(int), defaultdict(int)
|
|
137
|
+
for p in merged:
|
|
138
|
+
for a in _areas(p.get("paths")):
|
|
139
|
+
counts[a] += 1
|
|
140
|
+
if p["mg"] >= recent:
|
|
141
|
+
busy[a] += 1
|
|
142
|
+
areas = []
|
|
143
|
+
for a, people in ex.items():
|
|
144
|
+
authors = {who: w for who, w in people.items() if ex.authored[a].get(who)}
|
|
145
|
+
if not authors:
|
|
146
|
+
continue
|
|
147
|
+
owner = max(authors, key=authors.get)
|
|
148
|
+
share = authors[owner] / sum(authors.values())
|
|
149
|
+
quiet = (now - _t(last_seen[owner])).days if owner in last_seen else None
|
|
150
|
+
areas.append({"area": a, "prs": counts[a], "owner": owner, "share": round(share, 3),
|
|
151
|
+
"single_owner": share >= SINGLE_OWNER and counts[a] >= 3, "owner_quiet_days": quiet})
|
|
152
|
+
areas.sort(key=lambda x: -x["prs"])
|
|
153
|
+
hotspots = sorted(({"area": a, "prs_90d": c, "prs": counts[a]} for a, c in busy.items()), key=lambda x: -x["prs_90d"])
|
|
154
|
+
return {"areas": areas, "hotspots": hotspots[:10]}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_revert(p: dict) -> bool:
|
|
158
|
+
title, head = (p.get("ti") or "").lower(), (p.get("head") or "").lower()
|
|
159
|
+
return title.startswith('revert "') or head.startswith("revert-") or "hotfix" in title or "hotfix" in head
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def delivery(prs: list[dict], releases: list[dict], now: datetime, window_days: int = 90) -> dict:
|
|
163
|
+
"""Release frequency, merge-to-release time and a revert/hotfix-based change failure share (a labelled proxy)."""
|
|
164
|
+
since = _iso(now - timedelta(days=window_days))
|
|
165
|
+
rel = sorted((r for r in releases if not r.get("pre") and r.get("at") and r["at"] >= since), key=lambda r: r["at"])
|
|
166
|
+
merged = [p for p in _merged_humans(prs, now, days=window_days)]
|
|
167
|
+
if not rel:
|
|
168
|
+
return {"source": "merges", "releases": 0, "per_week": round(len(merged) / (window_days / 7), 2),
|
|
169
|
+
"lead_time_median_s": None, "change_fail": None, "recovery_median_s": None}
|
|
170
|
+
leads = []
|
|
171
|
+
for p in merged:
|
|
172
|
+
after = [r["at"] for r in rel if r["at"] >= p["mg"]]
|
|
173
|
+
if after:
|
|
174
|
+
leads.append(_secs(p["mg"], after[0]))
|
|
175
|
+
reverts = sorted(p["mg"] for p in merged if _is_revert(p))
|
|
176
|
+
failed, recovery = 0, []
|
|
177
|
+
for i, r in enumerate(rel):
|
|
178
|
+
nxt = rel[i + 1]["at"] if i + 1 < len(rel) else None
|
|
179
|
+
limit = min(filter(None, [nxt, _iso(_t(r["at"]) + REVERT_WINDOW)]))
|
|
180
|
+
if any(r["at"] <= x < limit for x in reverts):
|
|
181
|
+
failed += 1
|
|
182
|
+
if nxt:
|
|
183
|
+
recovery.append(_secs(r["at"], nxt))
|
|
184
|
+
return {"source": "releases", "releases": len(rel), "per_week": round(len(rel) / (window_days / 7), 2),
|
|
185
|
+
"lead_time_median_s": _median([x for x in leads if x is not None]),
|
|
186
|
+
"change_fail": {"failed": failed, "of": len(rel), "rate": round(failed / len(rel), 4), "proxy": True},
|
|
187
|
+
"recovery_median_s": _median([x for x in recovery if x is not None])}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def backtest(prs: list[dict], now: datetime, last: int = 50) -> dict:
|
|
191
|
+
"""For recent reviewed PRs, using only what was known before each opened: was a real reviewer in our top 3?"""
|
|
192
|
+
people = People(prs)
|
|
193
|
+
candidates = sorted((p for p in _merged_humans(prs, now)
|
|
194
|
+
if p.get("paths") and {e[2] for e in _human_reviews(p) if people.is_insider(e[2])}),
|
|
195
|
+
key=lambda p: p["mg"])[-last:]
|
|
196
|
+
hits = 0
|
|
197
|
+
for p in candidates:
|
|
198
|
+
ex = expertise(prs, now, before=p["cr"], people=people)
|
|
199
|
+
top = {s["login"] for s in suggest_reviewers(p, ex, load={}, owners={})}
|
|
200
|
+
if top & {e[2] for e in _human_reviews(p) if people.is_insider(e[2])}:
|
|
201
|
+
hits += 1
|
|
202
|
+
return {"checked": len(candidates), "hits": hits, "rate": round(hits / len(candidates), 3) if candidates else None}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from mergescout.bench.ci import tier
|
|
5
|
+
from stackvitals.ci import bench as ci_bench
|
|
6
|
+
from stackvitals.ci.bench import RANKS, _about, _label, _rank, fit
|
|
7
|
+
from stackvitals.core.actors import People
|
|
8
|
+
from stackvitals.flow.analyze import _human_reviews, _iso, _median, _secs, is_bot
|
|
9
|
+
|
|
10
|
+
FLOW_FILE = "flow-benchmarks.json"
|
|
11
|
+
MIN_PRS = 10
|
|
12
|
+
# Lower is better for waits; a higher merge rate is better; size is neither, so it gets plain words.
|
|
13
|
+
METRICS = [("first_review_hours", "First maintainer review after", "hours", -1),
|
|
14
|
+
("merge_hours", "Opened to merged", "hours", -1),
|
|
15
|
+
("outsider_merge_rate", "Outside contributors' PRs merged", "share", 1),
|
|
16
|
+
("size_lines", "Typical PR size", "lines", 0)]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def own_metrics(prs: list[dict], now: datetime, window_days: int = 90, people: People | None = None) -> dict | None:
|
|
20
|
+
"""This repo's numbers with exactly the crawl benchmarks' definitions (PRs opened in the window, people only,
|
|
21
|
+
maintainers found by what they do)."""
|
|
22
|
+
since = _iso(now - timedelta(days=window_days))
|
|
23
|
+
if people is None:
|
|
24
|
+
people = People(prs)
|
|
25
|
+
mine = [p for p in prs if p["cr"] >= since and not is_bot(p)]
|
|
26
|
+
if len(mine) < MIN_PRS:
|
|
27
|
+
return None
|
|
28
|
+
reviews, merges, sizes = [], [], []
|
|
29
|
+
for p in mine:
|
|
30
|
+
# Like the crawl: the first human review, counted only when a maintainer gave it.
|
|
31
|
+
humans = _human_reviews(p)
|
|
32
|
+
first = [humans[0][1]] if humans and people.is_insider(humans[0][2]) else []
|
|
33
|
+
# Times out of order (clock skew, imported history) are skipped, never divided.
|
|
34
|
+
if first and (wait := _secs(p["cr"], first[0])) is not None:
|
|
35
|
+
reviews.append(wait / 3600)
|
|
36
|
+
if p.get("mg") and (wait := _secs(p["cr"], p["mg"])) is not None:
|
|
37
|
+
merges.append(wait / 3600)
|
|
38
|
+
sizes.append((p.get("add") or 0) + (p.get("del") or 0))
|
|
39
|
+
outsiders = [p for p in mine if people.is_outside(p) and p.get("st") in ("MERGED", "CLOSED")]
|
|
40
|
+
return {"prs": len(mine), "first_review_hours": _median(reviews), "merge_hours": _median(merges),
|
|
41
|
+
"outsider_merge_rate": round(sum(p["st"] == "MERGED" for p in outsiders) / len(outsiders), 4) if outsiders else None,
|
|
42
|
+
"size_lines": _median(sizes)}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ok(q) -> bool:
|
|
46
|
+
return isinstance(q, dict) and all(isinstance(q.get(f"p{int(r * 100)}"), (int, float)) for r in RANKS)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def valid(data) -> dict | None:
|
|
50
|
+
# A bad file is ignored, metric by metric, so it can neither break a run nor reach a page.
|
|
51
|
+
if not isinstance(data, dict) or not isinstance(data.get("groups"), dict):
|
|
52
|
+
return None
|
|
53
|
+
groups = {}
|
|
54
|
+
for key, g in data["groups"].items():
|
|
55
|
+
if isinstance(key, str) and key.count("|") == 1 and isinstance(g, dict) and isinstance(g.get("repos"), int):
|
|
56
|
+
kept = {m: g[m] for m, *_ in METRICS if _ok(g.get(m))}
|
|
57
|
+
if kept:
|
|
58
|
+
groups[key] = {"repos": g["repos"], **kept}
|
|
59
|
+
return {**data, "groups": groups} if groups else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load(token: str | None, cache_dir: Path, client=None) -> dict | None:
|
|
63
|
+
return ci_bench.load(token, cache_dir, client=client, name=FLOW_FILE, url_env="STACKVITALS_FLOW_BENCH_URL",
|
|
64
|
+
validate=valid, default_url=None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def compare(own: dict | None, bench: dict | None, language: str | None, stars: int | None) -> dict | None:
|
|
68
|
+
"""Where this repo's review flow stands among similar public repos: the closest group with enough repos."""
|
|
69
|
+
bench = valid(bench) if bench else None
|
|
70
|
+
if not own or not bench:
|
|
71
|
+
return None
|
|
72
|
+
groups, size = bench["groups"], tier(stars)
|
|
73
|
+
key = next((k for k in (f"{language}|{size}", f"{language}|all", f"all|{size}", "all|all") if k in groups), None)
|
|
74
|
+
if key is None:
|
|
75
|
+
return None
|
|
76
|
+
g, metrics = groups[key], []
|
|
77
|
+
for name, label, kind, better in METRICS:
|
|
78
|
+
if own.get(name) is None or name not in g:
|
|
79
|
+
continue
|
|
80
|
+
r = _rank(own[name], g[name])
|
|
81
|
+
if better < 0:
|
|
82
|
+
place = f"slower than about {_about(r)}%" if r >= 0.5 else f"faster than about {_about(1 - r)}%"
|
|
83
|
+
elif better > 0:
|
|
84
|
+
place = f"above about {_about(r)}%" if r >= 0.5 else f"below about {_about(1 - r)}%"
|
|
85
|
+
else:
|
|
86
|
+
place = f"bigger than about {_about(r)}%" if r >= 0.5 else f"smaller than about {_about(1 - r)}%"
|
|
87
|
+
metrics.append({"key": name, "name": label, "kind": kind, "you": own[name], "typical": g[name]["p50"],
|
|
88
|
+
"place": place, "repos": g[name]["repos"]})
|
|
89
|
+
return {"group": _label(key), "repos": g["repos"], "built_at": bench.get("built_at"), "metrics": metrics,
|
|
90
|
+
"fit": fit(key, language, size)} if metrics else None
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from collections import Counter, defaultdict
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from mergescout.github.graphql import GraphQLError, RateLimited
|
|
7
|
+
from stackvitals.flow.analyze import INSIDERS, _iso, _pct
|
|
8
|
+
from stackvitals.flow.knowledge import _areas, area
|
|
9
|
+
from stackvitals.flow.store import FlowStore
|
|
10
|
+
|
|
11
|
+
# What maintainers bring up in reviews, in plain categories. Only the categories are kept, never the text.
|
|
12
|
+
CATEGORIES = {
|
|
13
|
+
"tests": r"\btests?\b|\btest cases?\b|\bcoverage\b",
|
|
14
|
+
"docs": r"\bdocs?\b|\bdocumentation\b|\breadme\b|\bdocstrings?\b",
|
|
15
|
+
"changelog": r"\bchange ?log\b|\bnews (entry|fragment)\b|\brelease notes?\b|\bchangesets?\b|\btowncrier\b",
|
|
16
|
+
"style": r"\bnit\b|\blint\w*\b|\bformat\w*\b|\btypos?\b|\brename\b|\bnaming\b|\bwhitespace\b|\bstyle\b",
|
|
17
|
+
"types": r"\btype (hint|annotation)s?\b|\btyping\b|\bmypy\b|\bpyright\b|\btype-?check\w*\b",
|
|
18
|
+
"compatibility": r"\bbreaking\b|\bbackwards?[- ]compat\w*\b|\bdeprecat\w*\b",
|
|
19
|
+
"performance": r"\bperformance\b|\bperf\b|\bslow(er)?\b|\ballocat\w*\b|\bbenchmark\w*\b",
|
|
20
|
+
"scope": r"\bseparate pr\b|\bsplit (this|the|it)\b|\bout of scope\b|\bunrelated\b|\bsmaller prs?\b|\btoo (big|large)\b",
|
|
21
|
+
"rebase": r"\brebase\b|\bmerge conflicts?\b|\bconflicts?\b",
|
|
22
|
+
"description": r"\bpr description\b|\bdescription\b|\blink(ed)? (the |an |to the )?issue\b|\bmotivation\b",
|
|
23
|
+
"security": r"\bsecurity\b|\bvulnerab\w*\b|\binjection\b|\bsanitiz\w*\b",
|
|
24
|
+
}
|
|
25
|
+
PATTERNS = {name: re.compile(rx, re.I) for name, rx in CATEGORIES.items()}
|
|
26
|
+
MIN_SCOPE_PRS = 10
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def categories(text: str | None) -> set[str]:
|
|
30
|
+
return {name for name, rx in PATTERNS.items() if text and rx.search(text)}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _by_maintainer(item: dict, author: str | None) -> bool:
|
|
34
|
+
who = item.get("author") or {}
|
|
35
|
+
return item.get("authorAssociation") in INSIDERS and who.get("__typename") == "User" and who.get("login") != author
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def asks_from(node: dict, author: str | None, paths: list[str]) -> list[list[str]]:
|
|
39
|
+
"""[category, area] pairs a PR's maintainers raised: review bodies apply to all its areas, line comments to their file's."""
|
|
40
|
+
found = set()
|
|
41
|
+
areas = _areas(paths) or {"(root)"}
|
|
42
|
+
for review in (node.get("reviews") or {}).get("nodes") or []:
|
|
43
|
+
if review and _by_maintainer(review, author):
|
|
44
|
+
found |= {(c, a) for c in categories(review.get("body")) for a in areas}
|
|
45
|
+
for thread in (node.get("reviewThreads") or {}).get("nodes") or []:
|
|
46
|
+
where = area(thread.get("path") or "") if thread and thread.get("path") else None
|
|
47
|
+
for comment in ((thread or {}).get("comments") or {}).get("nodes") or []:
|
|
48
|
+
if comment and _by_maintainer(comment, author):
|
|
49
|
+
found |= {(c, a) for c in categories(comment.get("body")) for a in ([where] if where else areas)}
|
|
50
|
+
return [list(x) for x in sorted(found)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _query(numbers: list[int]) -> str:
|
|
54
|
+
parts = "\n".join(
|
|
55
|
+
f" p{n}: pullRequest(number:{n}) {{ number\n"
|
|
56
|
+
" reviews(first:20) { nodes { body authorAssociation author { __typename login } } }\n"
|
|
57
|
+
" reviewThreads(first:30) { nodes { path comments(first:3) { nodes { body authorAssociation author { __typename login } } } } } }"
|
|
58
|
+
for n in numbers)
|
|
59
|
+
return f"query($owner:String!,$name:String!) {{ rateLimit {{ cost remaining resetAt }} repository(owner:$owner,name:$name) {{\n{parts}\n}} }}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _reviewed(p: dict) -> bool:
|
|
63
|
+
return any(e[0] == "review" and e[4] in INSIDERS and e[2] != p.get("au") for e in p["ev"]) or \
|
|
64
|
+
any(c[2] in INSIDERS and c[1] != p.get("au") for c in p["cm"])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def collect_asks(gql, store: FlowStore, owner: str, name: str, prs: list[dict], batch: int = 20, cap: int = 1000) -> dict:
|
|
68
|
+
"""Read review comments of closed PRs that maintainers commented on, once each, newest first, at most `cap` per run."""
|
|
69
|
+
done = store.asks()
|
|
70
|
+
todo = sorted((p for p in prs if p.get("st") != "OPEN" and p["n"] not in done and _reviewed(p)),
|
|
71
|
+
key=lambda p: p.get("cl") or "", reverse=True)
|
|
72
|
+
take, read = todo[:cap], 0
|
|
73
|
+
for i in range(0, len(take), batch):
|
|
74
|
+
chunk = take[i:i + batch]
|
|
75
|
+
try:
|
|
76
|
+
repo = gql.query(_query([p["n"] for p in chunk]), {"owner": owner, "name": name}).data["repository"]
|
|
77
|
+
except (GraphQLError, RateLimited):
|
|
78
|
+
# The hourly limit or a slow page: keep what was read, the next run continues.
|
|
79
|
+
break
|
|
80
|
+
store.put_asks({p["n"]: asks_from(repo.get(f"p{p['n']}") or {}, p.get("au"), p.get("paths") or []) for p in chunk})
|
|
81
|
+
read += len(chunk)
|
|
82
|
+
return {"read": read, "left": len(todo) - read}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def preflight(prs: list[dict], asks: dict[int, list], paths: list[str], now: datetime, months: int = 12) -> dict:
|
|
86
|
+
"""What maintainers usually ask for on PRs touching these paths, with counts; the whole repo when history is thin."""
|
|
87
|
+
since = _iso(now - timedelta(days=30 * months))
|
|
88
|
+
read = [p for p in prs if p["n"] in asks and (p.get("cl") or "") >= since]
|
|
89
|
+
targets = _areas(paths)
|
|
90
|
+
local = [p for p in read if _areas(p.get("paths")) & targets]
|
|
91
|
+
scoped = len(local) >= MIN_SCOPE_PRS
|
|
92
|
+
pool = local if scoped else read
|
|
93
|
+
counts = Counter()
|
|
94
|
+
for p in pool:
|
|
95
|
+
counts.update({cat for cat, a in asks[p["n"]] if not scoped or a in targets})
|
|
96
|
+
n = len(pool)
|
|
97
|
+
return {"scope": ", ".join(sorted(targets)) if scoped else "whole repo", "reviewed_prs": n,
|
|
98
|
+
"asks": [{"ask": cat, "share": round(c / n, 4), "prs": c} for cat, c in counts.most_common()] if n else []}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
TEST_FILE = re.compile(r"(^|/)(tests?|__tests__|spec|testing)(/|$)|(_test|\.test|\.spec|_spec)\.[a-z]+$|(^|/)test_[^/]+$", re.I)
|
|
102
|
+
CHANGELOG_FILE = re.compile(r"(^|/)(CHANGELOG|CHANGES|HISTORY|NEWS)(\.[a-z]+)?$|(^|/)(changelog\.d|changes|news|\.changeset|changelog)/", re.I)
|
|
103
|
+
DOCS_FILE = re.compile(r"(^|/)docs?/|\.(md|rst|adoc)$", re.I)
|
|
104
|
+
HABIT = 0.6
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _kinds(paths: list[str]) -> set[str]:
|
|
108
|
+
out = set()
|
|
109
|
+
for path in paths or []:
|
|
110
|
+
if TEST_FILE.search(path):
|
|
111
|
+
out.add("tests")
|
|
112
|
+
if CHANGELOG_FILE.search(path):
|
|
113
|
+
out.add("changelog")
|
|
114
|
+
elif DOCS_FILE.search(path):
|
|
115
|
+
out.add("docs")
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Habits:
|
|
120
|
+
"""Merged PRs indexed by folder once, so per-PR habits are cheap even with thousands of PRs."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, prs: list[dict], now: datetime, months: int = 12):
|
|
123
|
+
since = _iso(now - timedelta(days=30 * months))
|
|
124
|
+
merged = [p for p in prs if p.get("mg") and p["mg"] >= since and p.get("paths") and p.get("at") != "Bot"]
|
|
125
|
+
self.kinds = [_kinds(p["paths"]) for p in merged]
|
|
126
|
+
self.lines = [(p.get("add") or 0) + (p.get("del") or 0) for p in merged]
|
|
127
|
+
self.by_area = defaultdict(list)
|
|
128
|
+
for i, p in enumerate(merged):
|
|
129
|
+
for a in _areas(p["paths"]):
|
|
130
|
+
self.by_area[a].append(i)
|
|
131
|
+
self.cache = {}
|
|
132
|
+
|
|
133
|
+
def norms(self, paths: list[str]) -> dict:
|
|
134
|
+
# Only folders with merged history are named (a PR's own folder names are never echoed back).
|
|
135
|
+
targets = frozenset(a for a in _areas(paths) if a in self.by_area)
|
|
136
|
+
if targets not in self.cache:
|
|
137
|
+
local = sorted({i for a in targets for i in self.by_area[a]})
|
|
138
|
+
scoped = len(local) >= MIN_SCOPE_PRS
|
|
139
|
+
pool = local if scoped else range(len(self.lines))
|
|
140
|
+
n = len(pool)
|
|
141
|
+
kinds = Counter(k for i in pool for k in self.kinds[i])
|
|
142
|
+
lines = [self.lines[i] for i in pool]
|
|
143
|
+
self.cache[targets] = {
|
|
144
|
+
"scope": ", ".join(sorted(targets)) if scoped else "whole repo", "merged_prs": n,
|
|
145
|
+
"include": {k: round(kinds[k] / n, 4) if n else 0.0 for k in ("tests", "changelog", "docs")},
|
|
146
|
+
"size": {"median_lines": _pct(lines, 0.5), "p75_lines": _pct(lines, 0.75), "p90_lines": _pct(lines, 0.9)}}
|
|
147
|
+
return self.cache[targets]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def norms(prs: list[dict], paths: list[str], now: datetime, months: int = 12) -> dict:
|
|
151
|
+
"""What merged PRs touching these paths usually include (tests, changelog, docs) and how big they are."""
|
|
152
|
+
return Habits(prs, now, months).norms(paths)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
MAX_FOLDERS = 30
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def folder_habits(prs: list[dict], asks: dict[int, list], now: datetime, index: Habits | None = None, months: int = 12,
|
|
159
|
+
cap: int = MAX_FOLDERS) -> list[dict]:
|
|
160
|
+
"""Per folder with enough merged history (busiest first): the norms and review asks the CLI's preflight gives for it."""
|
|
161
|
+
index = index or Habits(prs, now, months)
|
|
162
|
+
out = []
|
|
163
|
+
for name, idx in sorted(index.by_area.items(), key=lambda kv: (-len(kv[1]), kv[0])):
|
|
164
|
+
if len(idx) < MIN_SCOPE_PRS or len(out) >= cap:
|
|
165
|
+
break
|
|
166
|
+
# A file inside the folder: the same lookup as a planned PR touching only that folder.
|
|
167
|
+
probe = "_" if name == "(root)" else f"{name}/_"
|
|
168
|
+
h = index.norms([probe])
|
|
169
|
+
asked = preflight(prs, asks, [probe], now, months)
|
|
170
|
+
scoped = asked["scope"] != "whole repo"
|
|
171
|
+
out.append({"area": name, "merged_prs": h["merged_prs"], "include": h["include"], "size": h["size"],
|
|
172
|
+
"reviewed_prs": asked["reviewed_prs"] if scoped else 0,
|
|
173
|
+
"asks": [a for a in asked["asks"] if a["share"] >= 0.05 and a["prs"] >= 3][:3] if scoped else []})
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def gaps(pr: dict, habits: dict) -> list[dict]:
|
|
178
|
+
"""Where a PR differs from what merged PRs here usually do: missing tests/changelog/docs, or unusually big."""
|
|
179
|
+
if not habits["merged_prs"]:
|
|
180
|
+
return []
|
|
181
|
+
have, out = _kinds(pr.get("paths")), []
|
|
182
|
+
# Only the first 50 file names are read; a bigger PR may well include what we couldn't see.
|
|
183
|
+
complete = not pr.get("files") or pr["files"] <= len(pr.get("paths") or [])
|
|
184
|
+
labels = {"tests": "test files", "changelog": "a changelog entry", "docs": "docs"}
|
|
185
|
+
for kind in ("tests", "changelog", "docs") if complete else ():
|
|
186
|
+
share = habits["include"][kind]
|
|
187
|
+
if share >= HABIT and kind not in have:
|
|
188
|
+
out.append({"what": kind, "why": f"{share:.0%} of merged PRs in {habits['scope']} include {labels[kind]}"})
|
|
189
|
+
lines = (pr.get("add") or 0) + (pr.get("del") or 0)
|
|
190
|
+
p90 = habits["size"]["p90_lines"]
|
|
191
|
+
if p90 is not None and lines > p90:
|
|
192
|
+
out.append({"what": "size", "why": f"{lines} lines; 90% of merged PRs in {habits['scope']} have at most {p90}"})
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def load_asks(path) -> dict[int, list]:
|
|
197
|
+
out = {}
|
|
198
|
+
try:
|
|
199
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
200
|
+
except OSError:
|
|
201
|
+
return out
|
|
202
|
+
for line in lines:
|
|
203
|
+
# A line cut short by an interrupted write is skipped, never fatal.
|
|
204
|
+
try:
|
|
205
|
+
rec = json.loads(line) if line.strip() else None
|
|
206
|
+
except ValueError:
|
|
207
|
+
continue
|
|
208
|
+
if isinstance(rec, dict) and "n" in rec:
|
|
209
|
+
out[rec["n"]] = rec.get("a") or []
|
|
210
|
+
return out
|