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,418 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from bisect import bisect_left, bisect_right
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from stackvitals.core import metrics
|
|
7
|
+
from stackvitals.core.actors import INSIDER_ASSOC, People, kind_of
|
|
8
|
+
from stackvitals.core.stats import median as _median
|
|
9
|
+
from stackvitals.core.stats import percentile as _pct
|
|
10
|
+
from stackvitals.core.time import iso as _iso
|
|
11
|
+
from stackvitals.core.time import parse as _t
|
|
12
|
+
from stackvitals.core.time import secs as _secs
|
|
13
|
+
|
|
14
|
+
INSIDERS = INSIDER_ASSOC
|
|
15
|
+
FAILING = {"FAILURE", "ERROR"}
|
|
16
|
+
STAGES = ("pickup", "review", "merge", "cycle")
|
|
17
|
+
# Nothing is "slow" before a day (repos that merge right after approval would flag minutes), and a wait
|
|
18
|
+
# beyond 30 days is stale backlog rather than today's work.
|
|
19
|
+
SLOW_FLOOR = 24 * 3600
|
|
20
|
+
STALE = 30 * 24 * 3600
|
|
21
|
+
MIN_BASELINE = 10
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_bot(p: dict) -> bool:
|
|
25
|
+
# Bots, coding agents and deleted accounts are not people (the crawl's rule).
|
|
26
|
+
return kind_of(p.get("au"), p.get("at")) != "person"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _human_reviews(p: dict) -> list[list]:
|
|
30
|
+
# GitHub's stage metrics skip bots, the author and Copilot; so do we.
|
|
31
|
+
return [e for e in p["ev"] if e[0] == "review" and e[2] and e[2] != p.get("au") and kind_of(e[2], e[5]) == "person"
|
|
32
|
+
and e[3] != "PENDING"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _insider(login: str | None, assoc: str | None, people: People | None) -> bool:
|
|
36
|
+
# With the repo's people known, insiders are found by what they do; without, by GitHub's label alone.
|
|
37
|
+
return people.is_insider(login) if people is not None else assoc in INSIDER_ASSOC
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ready_at(p: dict) -> str | None:
|
|
41
|
+
"""When the PR first asked for review: its creation, or leaving draft if it was opened as a draft."""
|
|
42
|
+
toggles = [e for e in p["ev"] if e[0] in ("ready", "draft")]
|
|
43
|
+
if toggles and toggles[0][0] == "ready":
|
|
44
|
+
return toggles[0][1]
|
|
45
|
+
if toggles or not p.get("draft"):
|
|
46
|
+
return p["cr"]
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def stages(p: dict) -> dict:
|
|
51
|
+
ready, merged = ready_at(p), p.get("mg")
|
|
52
|
+
reviews = [e for e in _human_reviews(p) if not merged or e[1] <= merged]
|
|
53
|
+
first = reviews[0][1] if reviews else None
|
|
54
|
+
final = reviews[-1][1] if reviews else None
|
|
55
|
+
return {"ready": ready, "reviewed": bool(reviews),
|
|
56
|
+
"pickup": _secs(ready, first), "review": _secs(first, final) if merged else None,
|
|
57
|
+
"merge": _secs(final, merged), "cycle": _secs(ready, merged)}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _asks(comment: list) -> bool:
|
|
61
|
+
# Older records have no flag: their comments count as asking, as before.
|
|
62
|
+
return comment[4] if len(comment) > 4 else True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Bots whose red marks rarely block a merge: performance and coverage reporters.
|
|
66
|
+
NOISY = re.compile(r"codspeed|codecov|coverage|benchmark|\bperf\b|performance|sonar|deepsource", re.I)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _failing_checks(p: dict, shared: set[str]) -> tuple[list[str], list[str]]:
|
|
70
|
+
"""Failing checks as (required, other); checks failing on many open PRs at once and noisy bots never count."""
|
|
71
|
+
chk = p.get("chk")
|
|
72
|
+
if not chk:
|
|
73
|
+
return (["checks"] if p.get("ci") in FAILING else []), []
|
|
74
|
+
live = [(name, required) for name, required in chk["failing"] if name not in shared]
|
|
75
|
+
return ([name for name, required in live if required],
|
|
76
|
+
[name for name, required in live if not required and not NOISY.search(name or "")])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Status labels maintainers set, matched on lower-case names with separators as spaces ("S-waiting-on-author").
|
|
80
|
+
AUTHOR_LABEL = re.compile(r"waiting (on|for) (the )?(author|contributor|submitter|op|response|feedback|reporter)"
|
|
81
|
+
r"|awaiting (author|changes|response|feedback|contributor)|changes requested|pending author"
|
|
82
|
+
r"|merge conflicts?|has conflicts?"
|
|
83
|
+
r"|needs? (changes|work|tests?|test coverage|rebase|more info|info|author|update|response|revision)")
|
|
84
|
+
CONFLICT_LABEL = re.compile(r"needs? rebase|merge conflicts?|has conflicts?")
|
|
85
|
+
READY_LABEL = re.compile(r"ready (to|for) merge|merge ready|waiting (on|for) (the )?(community )?(reviews?|maintainers?|merge)"
|
|
86
|
+
r"|awaiting (review|merge|maintainers?)|needs? review|ready for review")
|
|
87
|
+
# One or two red jobs while a reviewer is asked are often flaky; more than that is the change itself.
|
|
88
|
+
BROAD_FAILURE = 3
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _norm(label: str) -> str:
|
|
92
|
+
return " ".join(re.sub(r"[-_:/]+", " ", label.lower()).split())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _label(p: dict) -> tuple[str, str, str] | None:
|
|
96
|
+
"""The newest status label still on the PR, as (author | ready, name, since). The author's turn runs from the first
|
|
97
|
+
such label still on; a review wait from the last status-label change (clippy drops "community reviews" to hand over)."""
|
|
98
|
+
added, changed = {}, ""
|
|
99
|
+
for when, sign, name in p.get("lbe") or []:
|
|
100
|
+
if name and _kind(name):
|
|
101
|
+
changed = max(changed, when)
|
|
102
|
+
if sign == "+":
|
|
103
|
+
added[name] = max(added.get(name, ""), when)
|
|
104
|
+
found = [(added[name], kind, name) for name in p.get("lb") or [] if (kind := _kind(name)) and name in added]
|
|
105
|
+
if not found:
|
|
106
|
+
return None
|
|
107
|
+
when, kind, name = max(found)
|
|
108
|
+
if kind == "author":
|
|
109
|
+
return kind, name, min(w for w, k, _ in found if k == "author")
|
|
110
|
+
return kind, name, max(when, changed)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _kind(label: str) -> str | None:
|
|
114
|
+
return "author" if AUTHOR_LABEL.search(_norm(label)) else "ready" if READY_LABEL.search(_norm(label)) else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _work(p: dict) -> tuple[list[str], list[str]]:
|
|
118
|
+
"""The author's new work (own non-merge commits, by authored date) and all their pushes; others' commits don't count."""
|
|
119
|
+
cmt = p.get("cmt")
|
|
120
|
+
if not cmt:
|
|
121
|
+
# Older records know only the last commit, whoever made it.
|
|
122
|
+
return [t for t in [p.get("la") or p.get("lc")] if t], [t for t in [p.get("lc"), p.get("la")] if t]
|
|
123
|
+
mine = [c for c in cmt if c[2] in (p.get("au"), None)]
|
|
124
|
+
return [c[0] for c in mine if not c[3] and c[0]], [c[1] for c in mine if c[1]]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def who_acts_next(p: dict, now: datetime, heads: dict[str, int] | None = None, shared: set[str] | None = None,
|
|
128
|
+
people: People | None = None) -> dict:
|
|
129
|
+
"""Who holds the ball on an open PR, why, and since when (the event that put it there)."""
|
|
130
|
+
w = _decide(p, now, heads, shared, people)
|
|
131
|
+
# A reviewer the repo assigned (rust-clippy's rustbot) is named instead of unnamed maintainers.
|
|
132
|
+
owners = [a for a in p.get("asg") or [] if a != p.get("au")]
|
|
133
|
+
return {**w, "actor": "reviewers", "who": owners} if w["actor"] == "maintainers" and owners else w
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _decide(p: dict, now: datetime, heads: dict[str, int] | None, shared: set[str] | None,
|
|
137
|
+
people: People | None = None) -> dict:
|
|
138
|
+
au = p.get("au")
|
|
139
|
+
parent = (heads or {}).get(p.get("base"))
|
|
140
|
+
if parent and parent != p["n"]:
|
|
141
|
+
# A stacked PR waits for the PR it builds on; that wait is nobody's review delay.
|
|
142
|
+
return {"actor": "nobody", "who": [], "why": f"stacked on #{parent}: waits for it to merge first",
|
|
143
|
+
"since": p["cr"], "step": "stacked"}
|
|
144
|
+
if p.get("draft"):
|
|
145
|
+
since = max([e[1] for e in p["ev"] if e[0] == "draft"] or [p["cr"]])
|
|
146
|
+
return {"actor": "nobody", "who": [au], "why": "draft: the author is still working on it", "since": since,
|
|
147
|
+
"step": "draft"}
|
|
148
|
+
label = _label(p)
|
|
149
|
+
if p.get("mgs") == "CONFLICTING":
|
|
150
|
+
# GitHub keeps no time for a conflict; a "needs rebase" style label set after the last commit dates it better,
|
|
151
|
+
# unless the author already owed work from an earlier point (ansible: changes requested months before the conflict).
|
|
152
|
+
since = max(p.get("lc") or p["up"], label[2] if label and label[0] == "author" else "")
|
|
153
|
+
rest = [x for x in p.get("lb") or [] if not CONFLICT_LABEL.search(_norm(x))]
|
|
154
|
+
before = _decide({**p, "mgs": None, "lb": rest}, now, heads, shared, people)
|
|
155
|
+
if before["actor"] == "author":
|
|
156
|
+
since = min(since, before["since"])
|
|
157
|
+
return {"actor": "author", "who": [au], "why": "has merge conflicts with the base branch", "since": since, "step": "checks"}
|
|
158
|
+
if (p.get("chk") or {}).get("awaiting"):
|
|
159
|
+
return {"actor": "maintainers", "who": [], "why": "CI runs wait for a maintainer to approve them",
|
|
160
|
+
"since": p.get("lc") or p["up"], "step": "pickup"}
|
|
161
|
+
code, pushes = _work(p)
|
|
162
|
+
if label:
|
|
163
|
+
kind, name, when = label
|
|
164
|
+
# A maintainer's status label decides until the author pushes after a "waiting on author" one.
|
|
165
|
+
if kind == "author" and not any(t > when for t in pushes):
|
|
166
|
+
return {"actor": "author", "who": [au], "why": f'labelled "{name}"', "since": when, "step": "author_changes"}
|
|
167
|
+
if kind == "ready":
|
|
168
|
+
return {"actor": "reviewers" if p["rq"] else "maintainers", "who": list(p["rq"]), "why": f'labelled "{name}"',
|
|
169
|
+
"since": when, "step": "merge" if "merge" in _norm(name) else "pickup"}
|
|
170
|
+
required, other = _failing_checks(p, shared or set())
|
|
171
|
+
# A failing required check is always the author's; others are, unless a reviewer was asked and only one or two fail.
|
|
172
|
+
if required or (other and (not p["rq"] or len(other) >= BROAD_FAILURE)):
|
|
173
|
+
names = required or other
|
|
174
|
+
what = "checks are failing" if names == ["checks"] else f"checks are failing ({', '.join(names[:2])})"
|
|
175
|
+
return {"actor": "author", "who": [au], "why": f"{what} on the latest commit", "since": p.get("lc") or p["up"],
|
|
176
|
+
"step": "checks"}
|
|
177
|
+
# Anyone can press "request changes" on a public repo; only maintainers' reviews decide who holds the ball.
|
|
178
|
+
reviews = [e for e in _human_reviews(p) if _insider(e[2], e[4], people)]
|
|
179
|
+
latest = {}
|
|
180
|
+
for e in reviews:
|
|
181
|
+
if e[3] != "COMMENTED":
|
|
182
|
+
latest[e[2]] = e
|
|
183
|
+
# A formal change request is answered by a reply or new work, judged by when it was written (a rebase keeps that date);
|
|
184
|
+
# a question or comment by any push or reply. The ball moves at the author's first answer, not a later ping.
|
|
185
|
+
replies = [c[0] for c in p["cm"] if c[1] == au]
|
|
186
|
+
activity = sorted(replies + pushes + code)
|
|
187
|
+
blocking = sorted((e for e in latest.values() if e[3] == "CHANGES_REQUESTED"), key=lambda e: e[1])
|
|
188
|
+
if blocking:
|
|
189
|
+
names = [e[2] for e in blocking]
|
|
190
|
+
answers = sorted(t for t in replies + code if t > blocking[-1][1])
|
|
191
|
+
if answers:
|
|
192
|
+
return {"actor": "reviewers", "who": names, "why": f"the author updated it after changes requested by {', '.join(names)}",
|
|
193
|
+
"since": answers[0], "step": "rereview"}
|
|
194
|
+
return {"actor": "author", "who": [au], "why": f"changes requested by {', '.join(names)}", "since": blocking[-1][1],
|
|
195
|
+
"step": "author_changes"}
|
|
196
|
+
if p["rq"]:
|
|
197
|
+
# Each reviewer's latest request counts: a re-request restarts their clock.
|
|
198
|
+
latest_ask = [max(e[1] for e in p["ev"] if e[0] == "req" and e[2] == who) for who in p["rq"]
|
|
199
|
+
if any(e[0] == "req" and e[2] == who for e in p["ev"])]
|
|
200
|
+
return {"actor": "reviewers", "who": list(p["rq"]), "why": "review requested",
|
|
201
|
+
"since": min(latest_ask) if latest_ask else ready_at(p) or p["cr"], "step": "rereview" if reviews else "pickup"}
|
|
202
|
+
approvals = [e for e in latest.values() if e[3] == "APPROVED"]
|
|
203
|
+
if approvals:
|
|
204
|
+
last = max(approvals, key=lambda e: e[1])
|
|
205
|
+
# A maintainer's later comment-only review that raised points reopens it until new code arrives (older records lack the flag).
|
|
206
|
+
raised = [e for e in reviews if e[3] == "COMMENTED" and len(e) > 6 and e[6] and e[1] > last[1]]
|
|
207
|
+
if raised:
|
|
208
|
+
r = raised[-1]
|
|
209
|
+
later = sorted(t for t in code if t > r[1])
|
|
210
|
+
if later:
|
|
211
|
+
return {"actor": "reviewers", "who": [r[2]], "why": f"the author updated it after {r[2]}'s review",
|
|
212
|
+
"since": later[0], "step": "rereview"}
|
|
213
|
+
return {"actor": "author", "who": [au], "why": f"{r[2]} raised points after the approval", "since": r[1],
|
|
214
|
+
"step": "author_changes"}
|
|
215
|
+
if _insider(au, p.get("as"), people):
|
|
216
|
+
return {"actor": "author", "who": [au], "why": f"approved by {last[2]}; the author (a maintainer) can merge it",
|
|
217
|
+
"since": last[1], "step": "merge"}
|
|
218
|
+
return {"actor": "maintainers", "who": [], "why": f"approved by {last[2]}, waiting to be merged", "since": last[1],
|
|
219
|
+
"step": "merge"}
|
|
220
|
+
asked = [c[0] for c in p["cm"] if _insider(c[1], c[2], people) and c[1] != au and _asks(c)] + [e[1] for e in reviews]
|
|
221
|
+
spoke = [c[0] for c in p["cm"] if _insider(c[1], c[2], people) and c[1] != au] + [e[1] for e in reviews]
|
|
222
|
+
if asked and max(asked) > (activity[-1] if activity else ""):
|
|
223
|
+
return {"actor": "author", "who": [au], "why": "a maintainer asked something", "since": max(asked), "step": "author_reply"}
|
|
224
|
+
after = [t for t in activity if spoke and t > max(spoke)]
|
|
225
|
+
if after:
|
|
226
|
+
return {"actor": "maintainers", "who": [], "why": "the author replied", "since": after[0], "step": "maintainer_reply"}
|
|
227
|
+
if spoke:
|
|
228
|
+
return {"actor": "maintainers", "who": [], "why": "a maintainer commented but nobody reviewed yet", "since": max(spoke),
|
|
229
|
+
"step": "pickup"}
|
|
230
|
+
return {"actor": "maintainers", "who": [], "why": "ready, but nobody was asked to review", "since": ready_at(p) or p["cr"],
|
|
231
|
+
"step": "pickup"}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _author_responses(p: dict) -> list[float]:
|
|
235
|
+
# How long authors take to answer a change request: the next commit or comment after it.
|
|
236
|
+
au, out = p.get("au"), []
|
|
237
|
+
acts = sorted([c[0] for c in p["cm"] if c[1] == au] + ([p["lc"]] if p.get("lc") else []))
|
|
238
|
+
for e in _human_reviews(p):
|
|
239
|
+
if e[3] == "CHANGES_REQUESTED":
|
|
240
|
+
later = [a for a in acts if a > e[1]]
|
|
241
|
+
if later:
|
|
242
|
+
out.append(_secs(e[1], later[0]))
|
|
243
|
+
return [x for x in out if x is not None]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _baselines(prs: list[dict]) -> dict:
|
|
247
|
+
st = [stages(p) for p in prs if p.get("mg")]
|
|
248
|
+
values = {"pickup": [s["pickup"] for s in st if s["pickup"] is not None],
|
|
249
|
+
"merge": [s["merge"] for s in st if s["merge"] is not None],
|
|
250
|
+
"author": [x for p in prs for x in _author_responses(p)]}
|
|
251
|
+
return {**{k: _pct(v, 0.75) for k, v in values.items()}, "n": {k: len(v) for k, v in values.items()}}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _summary(values: list[float]) -> dict:
|
|
255
|
+
return {"median_s": _median(values), "p90_s": _pct(values, 0.9), "n": len(values)}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _measures(all_stages: list[dict], outsider: list[dict], humans_open: list[dict], now: datetime, window_days: int) -> dict:
|
|
259
|
+
# The registry's view of the same numbers: id, version, n, range and as-of, for every surface and the benchmarks.
|
|
260
|
+
def pick(name):
|
|
261
|
+
return [s[name] for s in all_stages if s[name] is not None]
|
|
262
|
+
found = [metrics.measure("flow.pickup_time", values=pick("pickup"), as_of=now, window_days=window_days),
|
|
263
|
+
metrics.measure("flow.cycle_time", values=pick("cycle"), as_of=now, window_days=window_days),
|
|
264
|
+
metrics.measure("flow.outsider_merge_rate", hits=sum(1 for p in outsider if p.get("mg")), n=len(outsider),
|
|
265
|
+
as_of=now, window_days=window_days),
|
|
266
|
+
metrics.measure("flow.open_prs", n=len(humans_open), as_of=now)]
|
|
267
|
+
return {m.metric: m.to_dict() for m in found}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _group(prs: list[dict]) -> dict:
|
|
271
|
+
merged = [p for p in prs if p.get("mg")]
|
|
272
|
+
unmerged = [p for p in prs if not p.get("mg")]
|
|
273
|
+
st = [stages(p) for p in merged]
|
|
274
|
+
out = {"merged": len(merged), "closed_unmerged": len(unmerged),
|
|
275
|
+
"merge_rate": round(len(merged) / len(prs), 4) if prs else None,
|
|
276
|
+
"merged_without_review": sum(not s["reviewed"] for s in st)}
|
|
277
|
+
for name in STAGES:
|
|
278
|
+
out[name] = _summary([s[name] for s in st if s[name] is not None])
|
|
279
|
+
return out
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
SHARED_WINDOW = timedelta(days=7)
|
|
283
|
+
SHARED_NEAREST = 6
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _broken(name: str, group: list[tuple]) -> bool:
|
|
287
|
+
# Failing for 3+ different authors and on 80%+ of the PRs that ran it; one person's repeated slip stays theirs.
|
|
288
|
+
failing = [au for _, failed, au in group if name in failed]
|
|
289
|
+
return len(set(failing)) >= 3 and len(failing) / max(len(group), 1) >= 0.8
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _nearest(runs: list[tuple], times: list[datetime], t: datetime, k: int) -> list[tuple]:
|
|
293
|
+
# The k runs closest in time to t, from a time-sorted list and its times.
|
|
294
|
+
i = bisect_left(times, t)
|
|
295
|
+
lo, hi, out = i - 1, i, []
|
|
296
|
+
while len(out) < k and (lo >= 0 or hi < len(runs)):
|
|
297
|
+
if hi < len(runs) and (lo < 0 or runs[hi][0] - t <= t - runs[lo][0]):
|
|
298
|
+
out.append(runs[hi])
|
|
299
|
+
hi += 1
|
|
300
|
+
else:
|
|
301
|
+
out.append(runs[lo])
|
|
302
|
+
lo -= 1
|
|
303
|
+
return out
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _shared_breakage(prs: list[dict]) -> dict[int, set[str]]:
|
|
307
|
+
"""Per PR, failing checks that were broken for everyone when its checks ran: judged among PRs that ran the same check,
|
|
308
|
+
pushed within 7 days of it or, in a quiet repo, the 6 pushed closest to it; never on today's PRs alone."""
|
|
309
|
+
by_check, mine = defaultdict(list), []
|
|
310
|
+
for p in prs:
|
|
311
|
+
chk = p.get("chk")
|
|
312
|
+
if chk:
|
|
313
|
+
t, failed = _t(p.get("lc") or p["up"]), {name for name, _ in chk.get("failing", [])}
|
|
314
|
+
mine.append((p["n"], t, failed))
|
|
315
|
+
for name in set(chk.get("ran") or failed) | failed:
|
|
316
|
+
by_check[name].append((t, failed, p.get("au")))
|
|
317
|
+
for runs in by_check.values():
|
|
318
|
+
runs.sort(key=lambda r: r[0])
|
|
319
|
+
# Times per check, built once: big repos have hundreds of open PRs with dozens of jobs each.
|
|
320
|
+
times = {name: [r[0] for r in runs] for name, runs in by_check.items()}
|
|
321
|
+
out = {}
|
|
322
|
+
for n, t, failed in mine:
|
|
323
|
+
out[n] = set()
|
|
324
|
+
for name in failed:
|
|
325
|
+
runs, ts = by_check[name], times[name]
|
|
326
|
+
week = runs[bisect_left(ts, t - SHARED_WINDOW):bisect_right(ts, t + SHARED_WINDOW)]
|
|
327
|
+
if _broken(name, week) or _broken(name, _nearest(runs, ts, t, SHARED_NEAREST)):
|
|
328
|
+
out[n].add(name)
|
|
329
|
+
return out
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
BASELINE_STEPS = {"pickup": "pickup", "rereview": "pickup", "merge": "merge", "author_changes": "author",
|
|
333
|
+
"author_reply": "author", "checks": "author", "maintainer_reply": "pickup"}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def analyze(data: dict, now: datetime, window_days: int = 90, people_speed: bool = False, owner: str | None = None,
|
|
337
|
+
people: People | None = None) -> dict:
|
|
338
|
+
"""Review flow of the last `window_days`, the open-PR queue and review load; people's speed only when asked."""
|
|
339
|
+
since = _iso(now - timedelta(days=window_days))
|
|
340
|
+
if people is None:
|
|
341
|
+
people = People(data["open"] + data["closed"])
|
|
342
|
+
humans_open = [p for p in data["open"] if not is_bot(p)]
|
|
343
|
+
closed = [p for p in data["closed"] if (p.get("cl") or "") >= since]
|
|
344
|
+
humans_closed = [p for p in closed if not is_bot(p)]
|
|
345
|
+
insider = [p for p in humans_closed if people.is_insider(p.get("au"))]
|
|
346
|
+
outsider = [p for p in humans_closed if not people.is_insider(p.get("au"))]
|
|
347
|
+
all_stages = [stages(p) for p in humans_closed if p.get("mg")]
|
|
348
|
+
baselines = _baselines(humans_closed)
|
|
349
|
+
# Outsiders' PRs usually wait longer; each PR is compared with its own group when there's enough history.
|
|
350
|
+
by_group = {"insider": _baselines(insider), "outsider": _baselines(outsider)}
|
|
351
|
+
# Branches of open PRs in this repo itself, to spot PRs stacked on another one.
|
|
352
|
+
heads = {p["head"]: p["n"] for p in data["open"] if owner and p.get("ho") == owner and p.get("head")}
|
|
353
|
+
shared = _shared_breakage(humans_open)
|
|
354
|
+
queue = []
|
|
355
|
+
for p in humans_open:
|
|
356
|
+
w = who_acts_next(p, now, heads, shared.get(p["n"]), people)
|
|
357
|
+
if w["actor"] == "nobody":
|
|
358
|
+
continue
|
|
359
|
+
waited = _secs(w["since"], _iso(now)) or 0.0
|
|
360
|
+
step = BASELINE_STEPS.get(w["step"], "pickup")
|
|
361
|
+
group = "insider" if people.is_insider(p.get("au")) else "outsider"
|
|
362
|
+
base = by_group[group][step] if by_group[group]["n"][step] >= MIN_BASELINE else baselines[step]
|
|
363
|
+
queue.append({"n": p["n"], "title": p.get("ti"), "author": p.get("au"), "outsider": group == "outsider",
|
|
364
|
+
**w, "waiting_s": waited, "usual_s": base, "stale": waited > STALE,
|
|
365
|
+
"slow": bool(base is not None and waited > max(base, SLOW_FLOOR))})
|
|
366
|
+
# Today's work first: overdue PRs, then the rest by wait; stale backlog last.
|
|
367
|
+
queue.sort(key=lambda q: (q["stale"], not q["slow"], -q["waiting_s"]))
|
|
368
|
+
load = defaultdict(list)
|
|
369
|
+
for p in humans_open:
|
|
370
|
+
if p.get("draft"):
|
|
371
|
+
continue
|
|
372
|
+
for who in p["rq"]:
|
|
373
|
+
asked = [e[1] for e in p["ev"] if e[0] == "req" and e[2] == who]
|
|
374
|
+
load[who].append(min(asked) if asked else ready_at(p) or p["cr"])
|
|
375
|
+
monthly = defaultdict(lambda: {"opened": 0, "merged": 0, "pickup": [], "cycle": []})
|
|
376
|
+
for p in [*humans_open, *[p for p in data["closed"] if not is_bot(p)]]:
|
|
377
|
+
if p["cr"] >= _iso(now - timedelta(days=365)):
|
|
378
|
+
monthly[p["cr"][:7]]["opened"] += 1
|
|
379
|
+
if p.get("mg") and p["mg"] >= _iso(now - timedelta(days=365)):
|
|
380
|
+
m = monthly[p["mg"][:7]]
|
|
381
|
+
m["merged"] += 1
|
|
382
|
+
if (s := stages(p))["pickup"] is not None:
|
|
383
|
+
m["pickup"].append(s["pickup"])
|
|
384
|
+
if s["cycle"] is not None:
|
|
385
|
+
m["cycle"].append(s["cycle"])
|
|
386
|
+
out = {"generated": _iso(now), "window_days": window_days,
|
|
387
|
+
"open": len(humans_open), "drafts": sum(1 for p in humans_open if p.get("draft")),
|
|
388
|
+
"merged": len(all_stages), "closed_unmerged": sum(1 for p in humans_closed if not p.get("mg")),
|
|
389
|
+
"stages": {name: _summary([s[name] for s in all_stages if s[name] is not None]) for name in STAGES},
|
|
390
|
+
"merged_without_review": sum(not s["reviewed"] for s in all_stages),
|
|
391
|
+
"groups": {"insider": _group(insider), "outsider": _group(outsider)},
|
|
392
|
+
"baselines": {k: v for k, v in baselines.items() if k != "n"}, "queue": queue,
|
|
393
|
+
"stale": sum(q["stale"] for q in queue),
|
|
394
|
+
"load": sorted(({"reviewer": who, "open_requests": len(ts), "oldest_since": min(ts)} for who, ts in load.items()),
|
|
395
|
+
key=lambda r: (-r["open_requests"], r["oldest_since"])),
|
|
396
|
+
"monthly": [{"month": k, "opened": v["opened"], "merged": v["merged"], "pickup_median_s": _median(v["pickup"]),
|
|
397
|
+
"cycle_median_s": _median(v["cycle"])}
|
|
398
|
+
for k, v in sorted(monthly.items())],
|
|
399
|
+
"bots": {"open": len(data["open"]) - len(humans_open), "closed": len(closed) - len(humans_closed)},
|
|
400
|
+
"measures": _measures(all_stages, outsider, humans_open, now, window_days)}
|
|
401
|
+
if people_speed:
|
|
402
|
+
out["responders"] = _responders([*humans_open, *humans_closed])
|
|
403
|
+
return out
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _responders(prs: list[dict]) -> list[dict]:
|
|
407
|
+
# Each reviewer's own answer time, from being asked (or the PR being ready) to their first review after it.
|
|
408
|
+
waits = defaultdict(list)
|
|
409
|
+
for p in prs:
|
|
410
|
+
ready = ready_at(p)
|
|
411
|
+
for who in {e[2] for e in _human_reviews(p)}:
|
|
412
|
+
asked = [e[1] for e in p["ev"] if e[0] == "req" and e[2] == who]
|
|
413
|
+
start = min(asked) if asked else ready
|
|
414
|
+
after = [e[1] for e in _human_reviews(p) if e[2] == who and start and e[1] >= start]
|
|
415
|
+
if start and after:
|
|
416
|
+
waits[who].append(_secs(start, after[0]))
|
|
417
|
+
return sorted(({"reviewer": who, "median_s": _median([w for w in ws if w is not None]), "n": len(ws)}
|
|
418
|
+
for who, ws in waits.items()), key=lambda r: -r["n"])
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Team automation, on by default and switched under [automation]: one suggested reviewer requested on ready pull requests
|
|
2
|
+
nobody was asked to review, a size label on each, and a "low risk" label on docs-only ones. Planned before the data
|
|
3
|
+
branch's commit (so overlapping runs never ask twice) and sent after it."""
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from stackvitals.flow.analyze import is_bot
|
|
8
|
+
|
|
9
|
+
# Line counts (added plus deleted) at each size's top; Kubernetes' and gitStream's usual cuts.
|
|
10
|
+
SIZES = (("size/XS", 10), ("size/S", 100), ("size/M", 500), ("size/L", 1000), ("size/XL", None))
|
|
11
|
+
DOCS_LABEL = "low risk: docs only"
|
|
12
|
+
# Markup files anywhere, anything under docs/, and the usual top files by exact name (requirements.txt is not docs).
|
|
13
|
+
DOCS = re.compile(r"(?i)(\.(md|mdx|rst|adoc)$|^docs?/|(^|/)(README|CHANGELOG|CONTRIBUTING|LICENSE|AUTHORS|NOTICE)"
|
|
14
|
+
r"(\.(md|rst|txt))?$)")
|
|
15
|
+
MAX_WRITES = 40
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def size_label(lines: int) -> str:
|
|
19
|
+
return next(name for name, top in SIZES if top is None or lines < top)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def plan(open_prs: list[dict], queue: list[dict], switches: dict, state: dict) -> tuple[list[dict], dict]:
|
|
23
|
+
"""The writes to make (at most MAX_WRITES) and the state to keep: PRs a reviewer was requested on."""
|
|
24
|
+
acts, asked = [], list(state.get("requested") or [])
|
|
25
|
+
by_n = {p["n"]: p for p in open_prs}
|
|
26
|
+
for p in open_prs:
|
|
27
|
+
if p.get("draft") or is_bot(p):
|
|
28
|
+
continue
|
|
29
|
+
labels = set(p.get("lb") or [])
|
|
30
|
+
if switches.get("size_labels"):
|
|
31
|
+
want = size_label((p.get("add") or 0) + (p.get("del") or 0))
|
|
32
|
+
stale = {x for x in labels if x.startswith("size/") and x != want}
|
|
33
|
+
if want not in labels:
|
|
34
|
+
acts.append({"kind": "label", "n": p["n"], "body": {"labels": [want]}})
|
|
35
|
+
acts += [{"kind": "unlabel", "n": p["n"], "label": x} for x in sorted(stale)]
|
|
36
|
+
if switches.get("docs_label"):
|
|
37
|
+
# Only when every changed file was read (the collector reads the first 50).
|
|
38
|
+
paths = p.get("paths") or []
|
|
39
|
+
docs = bool(paths) and len(paths) == (p.get("files") or len(paths)) and all(DOCS.search(x) for x in paths)
|
|
40
|
+
if docs and DOCS_LABEL not in labels:
|
|
41
|
+
acts.append({"kind": "label", "n": p["n"], "body": {"labels": [DOCS_LABEL]}})
|
|
42
|
+
elif not docs and DOCS_LABEL in labels:
|
|
43
|
+
acts.append({"kind": "unlabel", "n": p["n"], "label": DOCS_LABEL})
|
|
44
|
+
if switches.get("review_requests"):
|
|
45
|
+
for q in queue:
|
|
46
|
+
p = by_n.get(q["n"])
|
|
47
|
+
pick = next((s["login"] for s in q.get("suggested") or [] if s.get("login")), None)
|
|
48
|
+
if p and pick and q["n"] not in asked and not p.get("draft") and not is_bot(p) and pick != p.get("au"):
|
|
49
|
+
acts.append({"kind": "review", "n": q["n"], "body": {"reviewers": [pick]}})
|
|
50
|
+
asked.append(q["n"])
|
|
51
|
+
acts = acts[:MAX_WRITES]
|
|
52
|
+
sent = {a["n"] for a in acts if a["kind"] == "review"}
|
|
53
|
+
return acts, {"requested": [n for n in asked if n in sent or n in (state.get("requested") or [])][-500:]}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def send(api, acts: list[dict]) -> dict:
|
|
57
|
+
"""Make the planned writes; one refused for lack of permission is counted, the rest still go."""
|
|
58
|
+
done, refused = {"label": 0, "unlabel": 0, "review": 0, "comment": 0}, []
|
|
59
|
+
for a in acts:
|
|
60
|
+
try:
|
|
61
|
+
if a["kind"] == "label":
|
|
62
|
+
api.send("POST", f"/repos/{api.repo}/issues/{a['n']}/labels", a["body"])
|
|
63
|
+
elif a["kind"] == "comment":
|
|
64
|
+
api.send("POST", f"/repos/{api.repo}/issues/{a['n']}/comments", a["body"])
|
|
65
|
+
elif a["kind"] == "unlabel":
|
|
66
|
+
api.send("DELETE", f"/repos/{api.repo}/issues/{a['n']}/labels/{quote(a['label'], safe='')}")
|
|
67
|
+
else:
|
|
68
|
+
api.send("POST", f"/repos/{api.repo}/pulls/{a['n']}/requested_reviewers", a["body"])
|
|
69
|
+
done[a["kind"]] += 1
|
|
70
|
+
except Exception as exc: # noqa: BLE001
|
|
71
|
+
refused.append(f"#{a['n']} {a['kind']}: {exc}")
|
|
72
|
+
return {**done, "refused": refused[:5]}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from stackvitals.core.actors import People
|
|
4
|
+
from stackvitals.flow.analyze import analyze
|
|
5
|
+
from stackvitals.flow.knowledge import codeowners_for, delivery, expertise, knowledge_map, suggest_reviewers
|
|
6
|
+
from stackvitals.flow.peers import compare, own_metrics
|
|
7
|
+
from stackvitals.flow.preflight import Habits, folder_habits, gaps, preflight
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build(data: dict, repo: dict, now: datetime, owner: str | None = None, people_speed: bool = False,
|
|
11
|
+
window_days: int = 90, asks: dict | None = None, bench: dict | None = None, names: bool = True) -> dict:
|
|
12
|
+
"""Everything Team Flow shows: the queue (with reviewer suggestions where nobody was asked), review stages,
|
|
13
|
+
the knowledge map and delivery. People's answer times are used and shown only when people_speed is set; the
|
|
14
|
+
knowledge map names people only when names is set."""
|
|
15
|
+
prs = data["open"] + data["closed"]
|
|
16
|
+
# Who is an insider is decided once, by what people do, and shared by every part below.
|
|
17
|
+
people = People(prs)
|
|
18
|
+
result = analyze(data, now, window_days=window_days, people_speed=people_speed, owner=owner, people=people)
|
|
19
|
+
ex = expertise(prs, now, people=people)
|
|
20
|
+
load = {r["reviewer"]: r["open_requests"] for r in result["load"]}
|
|
21
|
+
speed = {r["reviewer"]: r["median_s"] for r in result.get("responders", [])} if people_speed else None
|
|
22
|
+
text = repo.get("codeowners")
|
|
23
|
+
by_n = {p["n"]: p for p in data["open"]}
|
|
24
|
+
habits_index = Habits(prs, now)
|
|
25
|
+
for q in result["queue"]:
|
|
26
|
+
p = by_n.get(q["n"])
|
|
27
|
+
if p:
|
|
28
|
+
# Where this PR differs from what merged PRs in its folders usually include.
|
|
29
|
+
q["gaps"] = gaps(p, habits_index.norms(p.get("paths") or []))
|
|
30
|
+
if p and q["actor"] == "maintainers" and q["step"] == "pickup" and not p["rq"]:
|
|
31
|
+
owners = {path: codeowners_for(path, text) for path in p.get("paths") or []} if text else {}
|
|
32
|
+
q["suggested"] = suggest_reviewers(p, ex, load, owners, speed)
|
|
33
|
+
result["knowledge"] = knowledge_map(prs, now)
|
|
34
|
+
if not names:
|
|
35
|
+
# A public repo's pages are public: the map names areas, never the person behind them (route work, don't rate people).
|
|
36
|
+
for a in result["knowledge"].get("areas") or []:
|
|
37
|
+
a["owner"] = None
|
|
38
|
+
result["delivery"] = delivery(prs, repo.get("releases") or [], now, window_days)
|
|
39
|
+
habits = habits_index.norms([])
|
|
40
|
+
# Maintainers' asks are shown only when they come up often enough to matter.
|
|
41
|
+
common = [a for a in preflight(prs, asks or {}, [], now)["asks"] if a["share"] >= 0.05 and a["prs"] >= 3]
|
|
42
|
+
# Per folder too, so the app's planned-PR check narrows to the folders it touches like the CLI does.
|
|
43
|
+
result["habits"] = {**habits, "asks": common, "folders": folder_habits(prs, asks or {}, now, habits_index)}
|
|
44
|
+
# Compared with similar public repos, only when a benchmarks file was found.
|
|
45
|
+
result["peers"] = compare(own_metrics(prs, now, window_days, people=people), bench, repo.get("language"), repo.get("stars"))
|
|
46
|
+
return result
|
stackvitals/flow/cli.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mergescout.github.graphql import GitHubGraphQL, GraphQLError
|
|
8
|
+
from stackvitals.ci.cli import home, token
|
|
9
|
+
from stackvitals.flow.action import write_flow_outputs
|
|
10
|
+
from stackvitals.flow.build import build
|
|
11
|
+
from stackvitals.flow import peers
|
|
12
|
+
from stackvitals.flow.collect import collect
|
|
13
|
+
from stackvitals.flow.preflight import collect_asks, gaps, norms, preflight
|
|
14
|
+
from stackvitals.flow import triage as triage_mod
|
|
15
|
+
from stackvitals.flow.report import render_text
|
|
16
|
+
from stackvitals.flow.store import FlowStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def local_gql(tok: str) -> GitHubGraphQL:
|
|
20
|
+
# On the user's machine a short wait for the limit is fine.
|
|
21
|
+
return GitHubGraphQL(tok, max_wait=120)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_model(tok: str | None) -> dict | None:
|
|
25
|
+
from stackvitals.ci import bench as ci_bench
|
|
26
|
+
return ci_bench.load(tok, home() / "bench", name=triage_mod.MODEL_FILE, url_env="STACKVITALS_TRIAGE_URL",
|
|
27
|
+
validate=triage_mod.valid, default_url=None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(argv: list[str] | None = None, now: datetime | None = None, make_gql=local_gql, tok: str | None = None) -> int:
|
|
31
|
+
parser = argparse.ArgumentParser(prog="stackvitals flow", description="Team Flow: who acts next on pull requests.")
|
|
32
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
33
|
+
rep = sub.add_parser("report", help="Read a repo's pull requests and show who acts next and how review flows")
|
|
34
|
+
rep.add_argument("repo", help="owner/name")
|
|
35
|
+
rep.add_argument("--months", type=int, default=12, help="history to read on the first run")
|
|
36
|
+
rep.add_argument("--window", type=int, default=90, help="days the flow numbers cover")
|
|
37
|
+
rep.add_argument("--json", action="store_true")
|
|
38
|
+
rep.add_argument("--html", help="also write a dashboard into this folder (without people's answer times)")
|
|
39
|
+
pre = sub.add_parser("preflight", help="What merged PRs here usually include, and what a planned PR is missing")
|
|
40
|
+
pre.add_argument("repo", help="owner/name")
|
|
41
|
+
pre.add_argument("--paths", nargs="+", default=[], help="files the PR changes")
|
|
42
|
+
pre.add_argument("--lines", type=int, default=0, help="lines the PR adds plus removes")
|
|
43
|
+
pre.add_argument("--months", type=int, default=12)
|
|
44
|
+
tri = sub.add_parser("triage", help="Private: chance each open outside PR gets merged within 30 days, and why")
|
|
45
|
+
tri.add_argument("repo", help="owner/name")
|
|
46
|
+
tri.add_argument("--pr", type=int, default=0, help="only this PR")
|
|
47
|
+
tri.add_argument("--months", type=int, default=12)
|
|
48
|
+
args = parser.parse_args(argv)
|
|
49
|
+
tok = tok or token()
|
|
50
|
+
if not tok:
|
|
51
|
+
print("A GitHub token is needed. Set GITHUB_TOKEN, or run `gh auth login`.", file=sys.stderr)
|
|
52
|
+
return 1
|
|
53
|
+
now = now or datetime.now(timezone.utc)
|
|
54
|
+
owner, name = args.repo.split("/", 1)
|
|
55
|
+
store = FlowStore(home() / "flow" / args.repo.replace("/", "__"))
|
|
56
|
+
try:
|
|
57
|
+
gql = make_gql(tok)
|
|
58
|
+
stats = collect(gql, store, owner, name, now, months=args.months,
|
|
59
|
+
log=lambda line: print(line, file=sys.stderr, flush=True))
|
|
60
|
+
collect_asks(gql, store, owner, name, store.load()["closed"], cap=1000)
|
|
61
|
+
except GraphQLError as exc:
|
|
62
|
+
print(f"GitHub refused: {exc}", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
if args.cmd == "triage":
|
|
65
|
+
model = load_model(tok)
|
|
66
|
+
if model is None:
|
|
67
|
+
print("The triage model could not be read (it is published with the daily data).", file=sys.stderr)
|
|
68
|
+
return 1
|
|
69
|
+
data = store.load()
|
|
70
|
+
if args.pr:
|
|
71
|
+
data = {"open": [p for p in data["open"] if p["n"] == args.pr], "closed": data["closed"]}
|
|
72
|
+
print(json.dumps(triage_mod.triage(data, model, now), indent=1))
|
|
73
|
+
return 0
|
|
74
|
+
if args.cmd == "preflight":
|
|
75
|
+
data = store.load()
|
|
76
|
+
prs = data["open"] + data["closed"]
|
|
77
|
+
habits = norms(prs, args.paths, now)
|
|
78
|
+
out = {"repo": args.repo, **habits, "gaps": gaps({"paths": args.paths, "add": args.lines}, habits),
|
|
79
|
+
"asks": preflight(prs, store.asks(), args.paths, now)["asks"][:5]}
|
|
80
|
+
print(json.dumps(out, indent=1))
|
|
81
|
+
return 0
|
|
82
|
+
# This view is the user's own, so it may include each reviewer's answer time.
|
|
83
|
+
result = build(store.load(), store.repo(), now, owner=owner, people_speed=True, window_days=args.window,
|
|
84
|
+
asks=store.asks(), bench=peers.load(tok, home() / "bench"))
|
|
85
|
+
if args.html:
|
|
86
|
+
write_flow_outputs(Path(args.html), result, args.repo)
|
|
87
|
+
if args.json:
|
|
88
|
+
print(json.dumps({**result, "collect": stats}, indent=1))
|
|
89
|
+
else:
|
|
90
|
+
print(render_text(result, args.repo))
|
|
91
|
+
if not stats["complete"]:
|
|
92
|
+
print("\n(Not all pull requests were read yet; run the same command again to continue.)")
|
|
93
|
+
return 0
|