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,954 @@
|
|
|
1
|
+
"""Delivery intelligence: LinearB's six PR phases (coding, pickup, review, approve, merge, deploy) with P50 and P75 and its
|
|
2
|
+
outlier rule, DevLake's DORA counting with its 2023 and 2021 tier tables, deployments and incidents found from what a
|
|
3
|
+
GitHub repo already has, stuck pull requests, PR sizes, and what can be said in public about AI-assisted work."""
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from bisect import bisect_left
|
|
7
|
+
from collections import Counter, defaultdict
|
|
8
|
+
from datetime import date, datetime, timedelta
|
|
9
|
+
|
|
10
|
+
from stackvitals.connectors import tracker as tracked
|
|
11
|
+
from stackvitals.core import investment
|
|
12
|
+
from stackvitals.connectors.base import BACKFILL_DAYS
|
|
13
|
+
from stackvitals.core.actors import kind_of
|
|
14
|
+
from stackvitals.core.ai_outcomes import assisted_by, is_revert, outcomes, revert_target, rework_rate
|
|
15
|
+
from stackvitals.core import agent_ledger, bugs, change_risk, changes, identity, releases, what_changed
|
|
16
|
+
from stackvitals.core.stats import median, percentile, quantile_range, wilson
|
|
17
|
+
from stackvitals.core.time import iso, secs
|
|
18
|
+
|
|
19
|
+
PHASES = ("coding", "pickup", "review", "approve", "merge", "deploy", "cycle", "cycle_to_merge")
|
|
20
|
+
# LinearB's 2026 bands on P75 hours (elite below the first number, good up to the second, fair up to the third).
|
|
21
|
+
LINEARB_BANDS = {"coding": (0.9, 4, 23), "pickup": (1, 4, 16), "review": (3, 14, 24), "approve": (10, 22, 42),
|
|
22
|
+
"merge": (1, 3, 16), "deploy": (16, 106, 277), "cycle": (25, 72, 161)}
|
|
23
|
+
SIZE_EDGES = (100, 155, 228)
|
|
24
|
+
OUTLIER_HOURS = 60 * 24
|
|
25
|
+
OUTLIER_MIN_PRS = 30
|
|
26
|
+
EVIDENCE_MAX = 500 # items kept per number in delivery.json; the longest lead times first, and the total is said
|
|
27
|
+
PHASE_EVIDENCE_MAX = 200 # pull requests kept per review phase, longest first
|
|
28
|
+
MIN_PRS = 10 # under this a phase shows its n and "not enough data"
|
|
29
|
+
LIGHTNING_HOURS = 20 / 60
|
|
30
|
+
HANGING_HOURS = 72
|
|
31
|
+
LONG_LIVED_HOURS = 30 * 24
|
|
32
|
+
BUSY_COMMENTS = 10
|
|
33
|
+
# Review bots that leave AI reviews on pull requests (their reviews never count as a person's pickup).
|
|
34
|
+
AI_REVIEWERS = {"coderabbitai", "copilot-pull-request-reviewer", "gemini-code-assist", "sourcery-ai", "greptile-apps",
|
|
35
|
+
"cursor", "qodo-merge-pro", "codeant-ai", "ellipsis-dev", "korbit-ai", "bito-code-review"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _hours(a: str | None, b: str | None) -> float | None:
|
|
39
|
+
s = secs(a, b)
|
|
40
|
+
return None if s is None else s / 3600
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _minutes_up(a: str | None, b: str | None) -> int | None:
|
|
44
|
+
s = secs(a, b) if a and b else None
|
|
45
|
+
return None if s is None else math.ceil(s / 60)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _minutes_down(a: str | None, b: str | None) -> int | None:
|
|
49
|
+
s = secs(a, b) if a and b else None
|
|
50
|
+
return None if s is None else int(s // 60)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _person(login: str | None, typ: str | None, author: str | None) -> bool:
|
|
54
|
+
return bool(login) and login != author and kind_of(login, typ) == "person"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def phases(p: dict, deployed_at: str | None = None, cal: dict | None = None) -> dict:
|
|
58
|
+
"""One merged PR's phases in hours, LinearB's definitions: coding (first commit to opened), pickup (ready to the first
|
|
59
|
+
other person's comment or review; to the merge when nobody answered), review (that to merge), approve (first activity
|
|
60
|
+
to first approval), merge (approval to merge), deploy (merge to the deployment that shipped it)."""
|
|
61
|
+
from stackvitals.core.calendar import elapsed_hours, is_set
|
|
62
|
+
from stackvitals.flow.analyze import ready_at
|
|
63
|
+
# With a working calendar, weekends and holidays are left out of every phase (DevLake's minutes below stay exact).
|
|
64
|
+
_hours = (lambda a, b: elapsed_hours(a, b, cal)) if is_set(cal) else globals()["_hours"] # noqa: N806
|
|
65
|
+
au, merged = p.get("au"), p.get("mg")
|
|
66
|
+
ready = ready_at(p) or p["cr"]
|
|
67
|
+
events = p.get("ev") or []
|
|
68
|
+
acts = [e[1] for e in events if e[0] == "review" and len(e) > 5 and e[3] != "PENDING" and _person(e[2], e[5], au)]
|
|
69
|
+
acts += [c[0] for c in p.get("cm") or [] if len(c) > 3 and c[0] and _person(c[1], c[3], au)]
|
|
70
|
+
acts = sorted(a for a in acts if a >= ready and (not merged or a <= merged))
|
|
71
|
+
first = acts[0] if acts else None
|
|
72
|
+
approvals = sorted(e[1] for e in events if e[0] == "review" and len(e) > 5 and e[3] == "APPROVED" and _person(e[2], e[5], au)
|
|
73
|
+
and (first is None or e[1] >= first) and (not merged or e[1] <= merged))
|
|
74
|
+
approval = approvals[0] if approvals else None
|
|
75
|
+
coding = max(0.0, (_hours(p.get("fa"), p["cr"]) or 0.0)) if p.get("fa") else None
|
|
76
|
+
pickup = _hours(ready, first or merged)
|
|
77
|
+
review = (_hours(first, merged) if first else 0.0) if merged else None
|
|
78
|
+
deploy = _hours(merged, deployed_at) if merged and deployed_at else None
|
|
79
|
+
to_merge = (coding or 0.0) + pickup + review if merged and pickup is not None and review is not None else None
|
|
80
|
+
# DevLake's pr_cycle_time: coding + opened to merged + deploy, each in whole minutes rounded up; its lead time's basis.
|
|
81
|
+
devlake = sum(_minutes_up(a, b) or 0 for a, b in ((p.get("fa"), p["cr"]), (p["cr"], merged), (merged, deployed_at))) \
|
|
82
|
+
if merged else None
|
|
83
|
+
return {"coding": coding, "pickup": pickup, "review": review, "approve": _hours(first, approval) if first and approval else None,
|
|
84
|
+
"merge": _hours(approval, merged) if approval and merged else None, "deploy": deploy,
|
|
85
|
+
"cycle_to_merge": to_merge, "cycle": to_merge + deploy if to_merge is not None and deploy is not None else None,
|
|
86
|
+
"devlake_cycle_min": devlake, "devlake_cycle": None if devlake is None else devlake / 60,
|
|
87
|
+
"reviewed": first is not None, "first_activity": first}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Words that mark a workflow as something other than shipping (labelled on 112 workflows of 30 public repos, 2026-10-20).
|
|
91
|
+
DEPLOY_DENY = re.compile(r"(?i)(preview|prepare|draft|create|check|verify|validat|\bdocs?\b|storybook|\btest|smoke|blog|notes|"
|
|
92
|
+
r"beta|pre-?release|canary|nightly|\bnext\b|\bdev\b|continuous|experiment|bump|sbom|\bsign|mirror|notif|populate|cleanup|"
|
|
93
|
+
r"ensure|block|schedule|pointer|merge|promote|update|\bstart|(?<![a-z])post(?![a-z])|gate\b|"
|
|
94
|
+
r"temporary|\btmp\b|scratch|\btag\b|authoriz|pages-build|snapshot|\bpr\b|pull request)")
|
|
95
|
+
# A plain deploy.yml still says when it ships somewhere else than production.
|
|
96
|
+
PLAIN_DENY = re.compile(r"(?i)(preview|\bdocs?\b|storybook|staging|\bdev\b|\btest|pages|demo|sandbox|canary|nightly|beta)")
|
|
97
|
+
# Side services ran on 0-7% of main-branch commits, continuous deploys on 30-66% (6 public repos, 2026-10-20).
|
|
98
|
+
SIDE_SERVICE_SHARE = 0.15
|
|
99
|
+
# Semver prerelease tags GitHub does not flag as prereleases (tauri-v3.0.0-alpha.5, v15.0.0-canary.3).
|
|
100
|
+
PRE_TAG = re.compile(r"(?i)[-.](alpha|beta|rc|canary|next|dev|pre|preview|nightly|snapshot|insiders)([-.\d]|$)")
|
|
101
|
+
# Explicit production deployments are sure; releases are versions users can install; workflow names are a guess.
|
|
102
|
+
# Stages of one commit within this many hours of its first, nothing else deployed between, are one rollout.
|
|
103
|
+
ROLLOUT_HOURS = 24
|
|
104
|
+
CONFIDENCE = {"deployments api": "high", "releases": "medium", "deploy workflows": "low", "release workflows": "low",
|
|
105
|
+
"merges": "low"}
|
|
106
|
+
VERSION_REF = re.compile(r"^v?\d+(\.\d+)*([-.+].*)?$")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def deploy_reason(name: str | None, path: str | None, pattern: str) -> tuple[str | None, str]:
|
|
110
|
+
"""deploy_kind with the reason, for the deploy dry run."""
|
|
111
|
+
stem = (path or "").rsplit("/", 1)[-1]
|
|
112
|
+
text = f"{name or ''} {stem}"
|
|
113
|
+
# A file named just deploy, release or publish ships whatever its display name says (labelled repos, 2026-10-22).
|
|
114
|
+
plain = re.fullmatch(r"(?i)(deploy|release|publish)\.ya?ml", stem)
|
|
115
|
+
if not re.search(pattern, text):
|
|
116
|
+
return None, "its name doesn't match deploy_workflow"
|
|
117
|
+
if m := DEPLOY_DENY.search(stem if plain else text):
|
|
118
|
+
return None, f'"{m[0]}" in its name marks it as not shipping to users'
|
|
119
|
+
if plain and (m := PLAIN_DENY.search(name or "")):
|
|
120
|
+
return None, f'its display name says it ships to "{m[0]}", not production'
|
|
121
|
+
kind = "deploy" if re.search(r"(?i)deploy|push-image", text) else "release"
|
|
122
|
+
return kind, f"a {kind} workflow"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def deploy_kind(name: str | None, path: str | None, pattern: str) -> str | None:
|
|
126
|
+
""""deploy" for deploy and image-push workflows, "release" for release and publish ones, None for the rest."""
|
|
127
|
+
return deploy_reason(name, path, pattern)[0]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_production(env: str | None, prod: set[str]) -> bool:
|
|
131
|
+
# The first word counts, so Vercel's "Production – web" and "production-eu" are production too.
|
|
132
|
+
return (env or "").lower() in prod or re.split(r"[\s\-–_/:]+", (env or "").lower(), maxsplit=1)[0] in prod
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Environments that are not production, for the dry run: shown, never counted.
|
|
136
|
+
ENV_STAGING = re.compile(r"(?i)\b(stag(e|ing)|pre-?prod|preview|dev(elop(ment)?)?|test(ing)?|qa|uat|sandbox|review|canary|beta)\b")
|
|
137
|
+
ENV_PUBLISH = re.compile(r"(?i)\b(pypi|testpypi|npm|crates?|nuget|maven|rubygems|github-pages|pages|release|publish|docs?|"
|
|
138
|
+
r"registry|docker)\b")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _ships(r: dict, pattern: str, default: str | None, any_result: bool = False, prod_branches: list[str] = ()) -> str | None:
|
|
142
|
+
"""The kind of deployment a successful run counts as (any finished run with any_result), else None. A deploy on a
|
|
143
|
+
production branch (release/*, production) ships as one on the default branch does."""
|
|
144
|
+
from fnmatch import fnmatch
|
|
145
|
+
kind = deploy_kind(r.get("wfn"), r.get("path"), pattern)
|
|
146
|
+
if kind is None or (r.get("c") != "success" and not any_result):
|
|
147
|
+
return None
|
|
148
|
+
br, ev = r.get("br") or "", r.get("ev")
|
|
149
|
+
if ev in ("release", "workflow_dispatch") or VERSION_REF.match(br):
|
|
150
|
+
return kind
|
|
151
|
+
# A release workflow on every push to the default branch mostly opens a version PR; only a deploy ships then.
|
|
152
|
+
on_prod = not default or br == default or any(fnmatch(br, pat) for pat in prod_branches)
|
|
153
|
+
return kind if kind == "deploy" and ev in ("push", "schedule") and on_prod else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _auto_order(found: dict, ci: dict, default: str | None) -> tuple[tuple, str | None]:
|
|
157
|
+
pushed = {r.get("sha") for r in ci.get("runs") or [] if r.get("ev") == "push" and (not default or r.get("br") == default)}
|
|
158
|
+
shipped_shas = {x["sha"] for x in found["deploy workflows"]}
|
|
159
|
+
# A deploy workflow on few of the default branch's commits is a side service (a docs site, a worker): releases first.
|
|
160
|
+
if pushed and len(shipped_shas & pushed) < SIDE_SERVICE_SHARE * len(pushed):
|
|
161
|
+
if not shipped_shas:
|
|
162
|
+
return ("deployments api", "releases", "deploy workflows", "release workflows"), None
|
|
163
|
+
return (("deployments api", "releases", "deploy workflows", "release workflows"),
|
|
164
|
+
f"deploy workflows ran on under {SIDE_SERVICE_SHARE:.0%} of the default branch's commits (a side "
|
|
165
|
+
"service, like a docs site), so releases come first")
|
|
166
|
+
# Every merge as a deployment is chosen, never guessed: it matched real deployments 8% of the time.
|
|
167
|
+
return tuple(k for k in found if k != "merges"), None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def deploy_attempts(repo: dict, ci: dict, cfg: dict, source: str | None, start: datetime, end: datetime) -> dict | None:
|
|
171
|
+
"""Every deploy attempt in the window from the chosen source, failed and cancelled ones too: how many succeeded, and
|
|
172
|
+
how long the successful ones took (Deployments API: created to success; deploy workflows: run start to end)."""
|
|
173
|
+
s, e = iso(start), iso(end)
|
|
174
|
+
d = cfg["delivery"]
|
|
175
|
+
rows = []
|
|
176
|
+
if source == "deployments api":
|
|
177
|
+
prod = {x.lower() for x in d["production"]}
|
|
178
|
+
for x in repo.get("deployments") or []:
|
|
179
|
+
state = {"SUCCESS": "success", "INACTIVE": "success", "FAILURE": "failure", "ERROR": "failure"}.get(x.get("state"))
|
|
180
|
+
if state and is_production(x.get("env"), prod) and s <= (x.get("at") or "") <= e:
|
|
181
|
+
rows.append((state, x.get("at"), x.get("done") if x.get("state") == "SUCCESS" else None))
|
|
182
|
+
elif source in ("deploy workflows", "release workflows"):
|
|
183
|
+
for r in ci.get("runs") or []:
|
|
184
|
+
if (r.get("c") in ("success", "failure", "cancelled", "timed_out") and s <= (r.get("cr") or "") <= e
|
|
185
|
+
and _ships(r, d["deploy_workflow"], repo.get("default_branch"), True, d.get("production_branches") or [])):
|
|
186
|
+
rows.append(({"timed_out": "failure"}.get(r["c"], r["c"]), r.get("st") or r.get("cr"), r.get("up")))
|
|
187
|
+
else:
|
|
188
|
+
return None
|
|
189
|
+
if not rows:
|
|
190
|
+
return None
|
|
191
|
+
took = sorted(_hours(a, b) * 60 for res, a, b in rows if res == "success" and a and b and b >= a)
|
|
192
|
+
done = [r for r in rows if r[0] in ("success", "failure")]
|
|
193
|
+
return {"source": source, "attempts": len(rows), "succeeded": sum(r[0] == "success" for r in rows),
|
|
194
|
+
"failed": sum(r[0] == "failure" for r in rows), "cancelled": sum(r[0] == "cancelled" for r in rows),
|
|
195
|
+
"success_rate": round(sum(r[0] == "success" for r in done) / len(done), 4) if done else None,
|
|
196
|
+
"median_minutes": round(took[len(took) // 2], 1) if took else None}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _final_runs(runs: list[dict]) -> list[dict]:
|
|
200
|
+
final = {}
|
|
201
|
+
for r in runs:
|
|
202
|
+
if r["id"] not in final or r["att"] > final[r["id"]]["att"]:
|
|
203
|
+
final[r["id"]] = r
|
|
204
|
+
return list(final.values())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def find_deployments(repo: dict, ci: dict, cfg: dict, prs: list[dict] | None = None) -> tuple[list[dict], str | None]:
|
|
208
|
+
"""Successful production deployments, from GitHub's Deployments API, else successful runs of a deploy workflow, else
|
|
209
|
+
published releases, else release or publish workflow runs on a version (D12); several commits in one run count once.
|
|
210
|
+
Release workflows come after releases: their names were right only 2 times in 3 on repos we had not tuned on."""
|
|
211
|
+
found = _sources(repo, ci, cfg, prs)
|
|
212
|
+
d = cfg["delivery"]
|
|
213
|
+
order = {"auto": _auto_order(found, ci, repo.get("default_branch"))[0], "deployments": ("deployments api",),
|
|
214
|
+
"releases": ("releases",), "workflows": ("deploy workflows", "release workflows"), "merges": ("merges",)}[d["source"]]
|
|
215
|
+
for key in order:
|
|
216
|
+
deps = sorted(({**x, "source": key} for x in found[key] if x["finished"]), key=lambda x: x["finished"])
|
|
217
|
+
if deps:
|
|
218
|
+
return deps, key
|
|
219
|
+
return [], None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _sources(repo: dict, ci: dict, cfg: dict, prs: list[dict] | None = None) -> dict:
|
|
223
|
+
"""Every deployment each source would count, before one source is chosen."""
|
|
224
|
+
d = cfg["delivery"]
|
|
225
|
+
prod = {x.lower() for x in d["production"]}
|
|
226
|
+
api = [{"id": f"deployment:{x.get('sha')}:{x.get('at')}", "sha": x.get("sha"), "source": "deployments api",
|
|
227
|
+
"name": x.get("env"),
|
|
228
|
+
# A superseded (inactive) deployment keeps no time of its success: its creation is the closest known.
|
|
229
|
+
"finished": x.get("done") if x.get("state") == "SUCCESS" and x.get("done") else x.get("at")}
|
|
230
|
+
# Recorded ones (sent from another tool through repository_dispatch) count as GitHub's own.
|
|
231
|
+
for x in (repo.get("deployments") or []) + ((repo.get("recorded") or {}).get("deployments") or [])
|
|
232
|
+
if is_production(x.get("env"), prod) and x.get("state") in ("SUCCESS", "INACTIVE")]
|
|
233
|
+
# One change reaching production is one deployment: the same commit rolled out to more regions or stages (eu, us,
|
|
234
|
+
# canary then full) within a day, nothing else between, counts once at its first; a rollback or redeploy counts again.
|
|
235
|
+
rollouts = []
|
|
236
|
+
for x in sorted((x for x in api if x["finished"]), key=lambda x: x["finished"]):
|
|
237
|
+
last = rollouts[-1] if rollouts else None
|
|
238
|
+
if last and x["sha"] and x["sha"] == last["sha"] and _hours(last["finished"], x["finished"]) <= ROLLOUT_HOURS:
|
|
239
|
+
last["stages"] += 1
|
|
240
|
+
else:
|
|
241
|
+
rollouts.append({**x, "stages": 1})
|
|
242
|
+
api = rollouts
|
|
243
|
+
# The commits each one shipped, when the repo query read them (a PR then goes to the deployment holding its merge).
|
|
244
|
+
ranges = repo.get("ranges") or {}
|
|
245
|
+
for prev, x in zip(api, api[1:]):
|
|
246
|
+
if (r := ranges.get(f"{prev['sha']}..{x['sha']}")) and r.get("complete"):
|
|
247
|
+
x["shipped"] = r["shas"]
|
|
248
|
+
default = repo.get("default_branch")
|
|
249
|
+
runs = defaultdict(list)
|
|
250
|
+
for r in _final_runs(ci.get("runs") or []):
|
|
251
|
+
if kind := _ships(r, d["deploy_workflow"], default, False, d.get("production_branches") or []):
|
|
252
|
+
runs[kind].append({"id": f"run:{r['id']}", "sha": r.get("sha"), "finished": r.get("up"), "name": r.get("wfn")})
|
|
253
|
+
shipped_commits = defaultdict(list)
|
|
254
|
+
for x in repo.get("releases") or []:
|
|
255
|
+
if x.get("at") and not x.get("pre") and not PRE_TAG.search(x.get("tag") or ""):
|
|
256
|
+
shipped_commits[x.get("sha") or x["tag"]].append(x)
|
|
257
|
+
releases = []
|
|
258
|
+
# Several packages released from one commit are one deployment, finished at the last one (as DevLake counts).
|
|
259
|
+
for group in shipped_commits.values():
|
|
260
|
+
main = min(group, key=lambda x: (len(x["tag"]), x["at"]))
|
|
261
|
+
releases.append({"id": f"release:{main['tag']}", "sha": main.get("sha"), "finished": max(x["at"] for x in group),
|
|
262
|
+
"source": "releases", "name": main["tag"] + (f" +{len(group) - 1}" if len(group) > 1 else "")})
|
|
263
|
+
# Teams that ship on merge (source = "merges"): each PR merged into the default branch is one deployment.
|
|
264
|
+
merges = [{"id": f"merge:{p['n']}", "sha": p.get("mc"), "finished": p["mg"], "source": "merges", "name": f"#{p['n']}"}
|
|
265
|
+
for p in prs or [] if p.get("mg") and (not default or not p.get("base") or p.get("base") == default)]
|
|
266
|
+
return {"deployments api": api, "deploy workflows": runs["deploy"], "releases": releases,
|
|
267
|
+
"release workflows": runs["release"], "merges": merges}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def explain_deployments(repo: dict, ci: dict, cfg: dict) -> dict:
|
|
271
|
+
"""The deploy dry run: which source counts and why the others don't, each workflow counted or skipped with the
|
|
272
|
+
reason, each environment counted or hidden, and the settings that decide it."""
|
|
273
|
+
d = cfg["delivery"]
|
|
274
|
+
default = repo.get("default_branch")
|
|
275
|
+
found = _sources(repo, ci, cfg)
|
|
276
|
+
deps, source = find_deployments(repo, ci, cfg)
|
|
277
|
+
order, why = _auto_order(found, ci, default)
|
|
278
|
+
order = {"auto": order, "deployments": ("deployments api",), "releases": ("releases",),
|
|
279
|
+
"workflows": ("deploy workflows", "release workflows"), "merges": ("merges",)}[d["source"]]
|
|
280
|
+
flows = {}
|
|
281
|
+
for r in _final_runs(ci.get("runs") or []):
|
|
282
|
+
if not (r.get("path") or "").startswith(".github/workflows/"):
|
|
283
|
+
continue
|
|
284
|
+
f = flows.setdefault(r["path"], {"name": r.get("wfn"), "path": r["path"], "counted": 0, "skipped": 0})
|
|
285
|
+
f["kind"], f["why"] = deploy_reason(r.get("wfn"), r.get("path"), d["deploy_workflow"])
|
|
286
|
+
# Its runs count only when workflows of its kind are the source chosen; otherwise it is passed over.
|
|
287
|
+
f["used"] = {"deploy": "deploy workflows", "release": "release workflows"}.get(f["kind"]) == source
|
|
288
|
+
if f["kind"]:
|
|
289
|
+
f["counted" if _ships(r, d["deploy_workflow"], default, False, d.get("production_branches") or []) else "skipped"] += 1
|
|
290
|
+
prod = {x.lower() for x in d["production"]}
|
|
291
|
+
envs = Counter(x.get("env") for x in repo.get("deployments") or [] if x.get("env"))
|
|
292
|
+
kind = lambda e: ("production" if is_production(e, prod) else "staging" if ENV_STAGING.search(e) # noqa: E731
|
|
293
|
+
else "publishing" if ENV_PUBLISH.search(e) else "other")
|
|
294
|
+
rel = repo.get("releases") or []
|
|
295
|
+
pre = sum(1 for x in rel if x.get("pre") or PRE_TAG.search(x.get("tag") or ""))
|
|
296
|
+
return {"source": source, "deployments": len(deps), "settings": {k: d[k] for k in ("source", "deploy_workflow", "production")},
|
|
297
|
+
"considered": [{"source": k, "found": len(found[k]), "used": k == source} for k in order],
|
|
298
|
+
"order_reason": why if d["source"] == "auto" else f'source = "{d["source"]}" in [delivery]',
|
|
299
|
+
"workflows": sorted(flows.values(), key=lambda f: (f["kind"] is None, f["path"])),
|
|
300
|
+
"environments": [{"name": e, "deployments": n, "as": kind(e), "counted": kind(e) == "production"}
|
|
301
|
+
for e, n in envs.most_common()],
|
|
302
|
+
"releases": {"published": len(rel) - pre, "prereleases_skipped": pre}}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def deployment_of(p: dict, deps: list[dict]) -> dict | None:
|
|
306
|
+
"""The deployment that shipped a merged PR: the one of its merge commit, else the one whose shipped commits hold it,
|
|
307
|
+
else the first one finished after its merge, but only inside the known history: before the oldest known
|
|
308
|
+
deployment, nothing says which one shipped it."""
|
|
309
|
+
if p.get("mc"):
|
|
310
|
+
for d in deps:
|
|
311
|
+
if d.get("sha") == p["mc"]:
|
|
312
|
+
return d
|
|
313
|
+
holding = [d for d in deps if p["mc"] in (d.get("shipped") or ())]
|
|
314
|
+
if holding:
|
|
315
|
+
return min(holding, key=lambda d: d["finished"])
|
|
316
|
+
if not any(d["finished"] < p["mg"] for d in deps):
|
|
317
|
+
return None
|
|
318
|
+
after = [d for d in deps if d["finished"] >= p["mg"]]
|
|
319
|
+
return min(after, key=lambda d: d["finished"]) if after else None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _ignored_revert(target: int | None, by_number: dict, ignore: list[str]) -> bool:
|
|
323
|
+
"""A revert of a PR that touched only ignored paths, or by an ignored author, is not an incident."""
|
|
324
|
+
if target is None or target not in by_number or not ignore:
|
|
325
|
+
return False
|
|
326
|
+
from fnmatch import fnmatch
|
|
327
|
+
undone = by_number[target]
|
|
328
|
+
if (undone.get("au") or "").lower() in {x.lower() for x in ignore}:
|
|
329
|
+
return True
|
|
330
|
+
paths = undone.get("paths") or []
|
|
331
|
+
return bool(paths) and all(any(fnmatch(path, pat) for pat in ignore if "/" in pat or "*" in pat) for path in paths)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def find_incidents(prs: list[dict], issues: list[dict], deps: list[dict], cfg: dict, extra=()) -> list[dict]:
|
|
335
|
+
"""Incidents without a paid tracker: issues with an incident label, merged revert PRs and merged hotfix PRs, plus a
|
|
336
|
+
connected tracker's (`extra`, with their own links); each linked to the last production deployment finished before
|
|
337
|
+
it began (DevLake's rule), or to none. A revert also names the deployment that shipped the PR it undoes (its fix
|
|
338
|
+
chain), or says the change never shipped when it was reverted before any deployment ran it."""
|
|
339
|
+
d = cfg["delivery"]
|
|
340
|
+
incident_labels = {x.lower() for x in d["incident_labels"]}
|
|
341
|
+
hotfix_labels = {x.lower() for x in d["hotfix_labels"]}
|
|
342
|
+
# An empty incident_sources list counts every GitHub source; a list counts only those named.
|
|
343
|
+
wanted = set(d.get("incident_sources") or ("issues", "reverts", "hotfixes", "fixes"))
|
|
344
|
+
fix_res = [re.compile(x) for x in d.get("fix_patterns") or []]
|
|
345
|
+
by_number = {p["n"]: p for p in prs if p.get("mg")}
|
|
346
|
+
out = [{"source": "issue", "number": i["n"], "created": i["cr"], "resolved": i.get("cl")}
|
|
347
|
+
for i in issues or [] if "issues" in wanted and any(str(x).lower() in incident_labels for x in i.get("lb") or [])]
|
|
348
|
+
for p in prs:
|
|
349
|
+
if not p.get("mg"):
|
|
350
|
+
continue
|
|
351
|
+
if is_revert(p):
|
|
352
|
+
if "reverts" in wanted and not _ignored_revert(revert_target(p, by_number), by_number, d.get("ignore_reverts_of") or []):
|
|
353
|
+
out.append({"source": "revert", "number": p["n"], "created": p["cr"], "resolved": p["mg"]})
|
|
354
|
+
elif any(str(x).lower() in hotfix_labels for x in p.get("lb") or []):
|
|
355
|
+
if "hotfixes" in wanted:
|
|
356
|
+
out.append({"source": "hotfix", "number": p["n"], "created": p["cr"], "resolved": p["mg"]})
|
|
357
|
+
elif "fixes" in wanted and (m := next((m for r in fix_res for s in (p.get("ti") or "", p.get("head") or "")
|
|
358
|
+
if (m := r.search(s))), None)):
|
|
359
|
+
# A fix that names the PR it fixes: that PR's deployment is blamed, as a revert's is.
|
|
360
|
+
fixes = int(m.group(1)) if m.group(1) and m.group(1).isdigit() else None
|
|
361
|
+
out.append({"source": "fix", "number": p["n"], "created": p["cr"], "resolved": p["mg"], "fixes": fixes})
|
|
362
|
+
out += [dict(x) for x in extra]
|
|
363
|
+
for inc in out:
|
|
364
|
+
target = revert_target(by_number[inc["number"]], by_number) if inc["source"] == "revert" else \
|
|
365
|
+
inc.get("fixes") if inc["source"] == "fix" and inc.get("fixes") in by_number else None
|
|
366
|
+
shipped = deployment_of(by_number[target], deps) if target is not None else None
|
|
367
|
+
# Only a deployment that ran the change before the revert merged failed; one shipping both shipped no failure.
|
|
368
|
+
live = bool(shipped) and shipped["finished"] < (inc["resolved"] or "~")
|
|
369
|
+
# No deployment yet after a change merged inside the known history: it never shipped either.
|
|
370
|
+
unshipped = (target is not None and not shipped and inc["resolved"] is not None
|
|
371
|
+
and any(d["finished"] < by_number[target]["mg"] for d in deps))
|
|
372
|
+
inc["fix_chain_deployment"] = shipped["id"] if live else None
|
|
373
|
+
inc["never_shipped"] = (bool(shipped) and not live) or unshipped
|
|
374
|
+
before = [x for x in deps if x["finished"] <= inc["created"]]
|
|
375
|
+
dep = max(before, key=lambda x: x["finished"]) if before else None
|
|
376
|
+
inc["deployment"] = dep["id"] if dep else None
|
|
377
|
+
inc["deployment_finished"] = dep["finished"] if dep else None
|
|
378
|
+
inc["hours_to_restore"] = _hours(dep["finished"], inc["resolved"]) if dep and inc["resolved"] else None
|
|
379
|
+
return sorted(out, key=lambda x: x["created"])
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
PAGERS = {"pagerduty": "PagerDuty", "incident_io": "incident.io", "recorded": "recorded incidents"}
|
|
383
|
+
TRACKER_NAMES = {"jira": "Jira", "linear": "Linear"}
|
|
384
|
+
BUILT_IN_INCIDENTS = "GitHub issues, reverts and hotfixes"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def incidents_for(prs: list[dict], issues: list[dict], deps: list[dict], cfg: dict, tracker: dict | None,
|
|
388
|
+
recorded: dict | None = None) -> tuple[list[dict], str]:
|
|
389
|
+
"""The incidents DORA counts and where they came from: a connected pager tool's (or ones recorded from another tool)
|
|
390
|
+
alone when there are any (labels, reverts and hotfixes would count the same outage twice), else GitHub's plus a
|
|
391
|
+
tracker's incident issues."""
|
|
392
|
+
from stackvitals.core import recorded as recorded_mod
|
|
393
|
+
extra = tracked.incidents(tracker["issues"]) if tracker else []
|
|
394
|
+
pager = [i for i in extra if i["source"] in PAGERS] + recorded_mod.incidents(recorded)
|
|
395
|
+
chosen = set(cfg["delivery"].get("incident_sources") or ())
|
|
396
|
+
if chosen:
|
|
397
|
+
# The settings name the sources: exactly those, combined (GitHub's are filtered inside find_incidents).
|
|
398
|
+
picked = [i for i in pager if ("recorded" if i["source"] == "recorded" else "pager") in chosen]
|
|
399
|
+
picked += [i for i in extra if i["source"] not in PAGERS and "tracker" in chosen]
|
|
400
|
+
github = chosen & {"issues", "reverts", "hotfixes", "fixes"}
|
|
401
|
+
said = sorted(github) + sorted({PAGERS.get(i["source"]) or TRACKER_NAMES.get(i["source"], i["source"]) for i in picked})
|
|
402
|
+
return (find_incidents(prs if github else [], issues if github else [], deps, cfg, extra=picked),
|
|
403
|
+
", ".join(said) or "none chosen")
|
|
404
|
+
if pager:
|
|
405
|
+
return find_incidents([], [], deps, cfg, extra=pager), " and ".join(sorted({PAGERS[i["source"]] for i in pager}))
|
|
406
|
+
names = sorted({TRACKER_NAMES.get(i["source"], i["source"]) for i in extra})
|
|
407
|
+
return find_incidents(prs, issues, deps, cfg, extra=extra), BUILT_IN_INCIDENTS + (f", and {' and '.join(names)}" if names else "")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _ranks(v: list) -> list[float]:
|
|
411
|
+
# SQL's percent_rank over sorted values: ties share the lowest rank, and a single row ranks 0.
|
|
412
|
+
return [bisect_left(v, x) / (len(v) - 1) if len(v) > 1 else 0.0 for x in v]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def devlake_median(values: list[float]) -> float | None:
|
|
416
|
+
"""DevLake's median: the largest value whose percent rank is at most 0.5 (the lower middle value)."""
|
|
417
|
+
v = sorted(x for x in values if x is not None)
|
|
418
|
+
return max((x for x, r in zip(v, _ranks(v)) if r <= 0.5), default=None)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def devlake_upper_median(values: list[float]) -> float | None:
|
|
422
|
+
"""The smallest value whose percent rank is at least 0.5, DevLake's rule for six-month windows (None for one row)."""
|
|
423
|
+
v = sorted(x for x in values if x is not None)
|
|
424
|
+
return min((x for x, r in zip(v, _ranks(v)) if r >= 0.5), default=None)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _span(rng, cuts, inclusive=False) -> list[str] | None:
|
|
428
|
+
# The tiers a range reaches across, when its two ends fall in different ones; None when settled or unknown.
|
|
429
|
+
if not rng:
|
|
430
|
+
return None
|
|
431
|
+
ends = [_tier(rng[0], cuts, inclusive), _tier(rng[1], cuts, inclusive)]
|
|
432
|
+
return ends if ends[0] != ends[1] else None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _tier(value, cuts, inclusive=False) -> str | None:
|
|
436
|
+
if value is None:
|
|
437
|
+
return None
|
|
438
|
+
for limit, name in zip(cuts, ("elite", "high", "medium")):
|
|
439
|
+
if (value <= limit) if inclusive else (value < limit):
|
|
440
|
+
return name
|
|
441
|
+
return "low"
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _per_six_months(months: dict) -> float | None:
|
|
445
|
+
"""DevLake's six-month count: rolling six-month sums, every sixth month from the newest, upper median. It is None
|
|
446
|
+
under seven months of data, and DevLake then shows N/A for a low or (2021) medium frequency."""
|
|
447
|
+
ms = sorted(months)
|
|
448
|
+
rolling = [sum(months[m] for m in ms[max(0, i - 5):i + 1]) for i in range(len(ms))]
|
|
449
|
+
rn, count = {}, defaultdict(int)
|
|
450
|
+
for i in sorted(range(len(ms)), key=lambda i: ms[i], reverse=True):
|
|
451
|
+
count[ms[i].year // 6] += 1 # MySQL reads DATE_FORMAT(month, '%Y-%m') DIV 6 as the year DIV 6
|
|
452
|
+
rn[i] = count[ms[i].year // 6]
|
|
453
|
+
return devlake_upper_median([rolling[i] for i in range(len(ms)) if rn[i] % 6 == 1])
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def dora(deps: list[dict], deployed: list[dict], incidents: list[dict], start: datetime, end: datetime, report: str = "2023") -> dict:
|
|
457
|
+
"""DORA's first four metrics exactly as DevLake's DORA dashboard SQL counts them: deployment days per week and month over the
|
|
458
|
+
calendar days after the start up to the end's day (weeks with none count as zero), the median DevLake PR cycle time
|
|
459
|
+
in whole minutes of PRs deployed in the range, deployments with an incident over deployments, and the median whole
|
|
460
|
+
minutes from a failed deployment to its incident's resolution (2021: incident opened to resolved)."""
|
|
461
|
+
s, e = iso(start), iso(end)
|
|
462
|
+
in_win = [x for x in deps if s <= x["finished"] <= e]
|
|
463
|
+
ids = {x["id"] for x in in_win}
|
|
464
|
+
finished = {x["id"]: x["finished"] for x in deps}
|
|
465
|
+
dep_days = {x["finished"][:10] for x in deps}
|
|
466
|
+
calendar, k = [], 0
|
|
467
|
+
while end - timedelta(days=k) > start:
|
|
468
|
+
calendar.append((end - timedelta(days=k)).date())
|
|
469
|
+
k += 1
|
|
470
|
+
weeks, months = defaultdict(int), defaultdict(int)
|
|
471
|
+
for day in calendar:
|
|
472
|
+
weeks[day - timedelta(days=day.weekday())] += day.isoformat() in dep_days
|
|
473
|
+
months[day.replace(day=1)] += day.isoformat() in dep_days
|
|
474
|
+
per_week, per_month = devlake_median(list(weeks.values())), devlake_median(list(months.values()))
|
|
475
|
+
per_six = _per_six_months(months)
|
|
476
|
+
if not deps or per_week is None:
|
|
477
|
+
freq = None
|
|
478
|
+
elif report == "2021":
|
|
479
|
+
freq = "elite" if per_week >= 5 else "high" if per_month >= 1 else None if per_six is None else \
|
|
480
|
+
"medium" if per_six >= 1 else "low"
|
|
481
|
+
else:
|
|
482
|
+
freq = "elite" if per_week >= 5 else "high" if per_week >= 1 else "medium" if per_month >= 1 else \
|
|
483
|
+
"low" if per_six is not None else None
|
|
484
|
+
lead_minutes = [x["cycle_min"] for x in deployed if x["deployment"] in ids and x["cycle_min"] is not None]
|
|
485
|
+
lead = devlake_median(lead_minutes)
|
|
486
|
+
lead_cuts = (60, 7 * 24 * 60, 180 * 24 * 60) if report == "2021" else (24 * 60, 7 * 24 * 60, 30 * 24 * 60)
|
|
487
|
+
failed = {i["deployment"] for i in incidents if i.get("deployment") in ids}
|
|
488
|
+
# DevLake shows N/A instead of a rate when no incident was found at all.
|
|
489
|
+
cfr = round(len(failed) / len(in_win), 4) if in_win and incidents else None
|
|
490
|
+
cfr_cuts = (0.15, 0.20, 0.30) if report == "2021" else (0.05, 0.10, 0.15)
|
|
491
|
+
# The same rate with each revert tied to the deployment that shipped what it undoes (time order for the rest).
|
|
492
|
+
chained = {i.get("fix_chain_deployment") or i.get("deployment") for i in incidents if not i.get("never_shipped")} & ids
|
|
493
|
+
cfr_chain = round(len(chained) / len(in_win), 4) if in_win and incidents else None
|
|
494
|
+
reverts = [i for i in incidents if i.get("source") == "revert" and s <= i["created"] <= e]
|
|
495
|
+
linkage = {"reverts": len(reverts), "fix_chain_found": sum(1 for i in reverts if i.get("fix_chain_deployment")),
|
|
496
|
+
"disagree": sum(1 for i in reverts if i.get("fix_chain_deployment")
|
|
497
|
+
and i["fix_chain_deployment"] != i.get("deployment")),
|
|
498
|
+
"never_shipped": sum(1 for i in reverts if i.get("never_shipped"))}
|
|
499
|
+
resolved = [i for i in incidents if i.get("resolved") and s <= i["resolved"] <= e]
|
|
500
|
+
restore = [m for i in resolved if i.get("deployment") in ids
|
|
501
|
+
if (m := _minutes_down(finished[i["deployment"]], i["resolved"])) is not None]
|
|
502
|
+
recovery = devlake_median(restore)
|
|
503
|
+
mttr = devlake_median([m for i in resolved if (m := _minutes_down(i["created"], i["resolved"])) is not None])
|
|
504
|
+
rec_value = mttr if report == "2021" else recovery
|
|
505
|
+
by_month = Counter(x["finished"][2:7].replace("-", "/") for x in in_win)
|
|
506
|
+
month, monthly = start.date().replace(day=1), []
|
|
507
|
+
while month <= end.date().replace(day=1):
|
|
508
|
+
label = month.strftime("%y/%m")
|
|
509
|
+
monthly.append({"month": label, "deployments": by_month.get(label, 0)})
|
|
510
|
+
month = (month + timedelta(days=32)).replace(day=1)
|
|
511
|
+
|
|
512
|
+
def hours(minutes):
|
|
513
|
+
return None if minutes is None else round(minutes / 60, 2)
|
|
514
|
+
|
|
515
|
+
# What each number was computed from, so the app can open a number into its pull requests and deployments.
|
|
516
|
+
short = {x["id"]: (x.get("sha") or "")[:12] for x in deps}
|
|
517
|
+
lead_rows = sorted(([x.get("pr"), short.get(x["deployment"]), x["cycle_min"]] for x in deployed
|
|
518
|
+
if x["deployment"] in ids and x["cycle_min"] is not None), key=lambda r: -r[2])
|
|
519
|
+
failed_rows = sorted([short.get(i["deployment"]), i.get("source"), i.get("number")] for i in incidents
|
|
520
|
+
if i.get("deployment") in ids)
|
|
521
|
+
restore_rows = [[short.get(i["deployment"]), i.get("source"), i.get("number"), m] for i in resolved if i.get("deployment") in ids
|
|
522
|
+
if (m := _minutes_down(finished[i["deployment"]], i["resolved"])) is not None]
|
|
523
|
+
evidence = {"deployments": [[short[x["id"]], x["finished"]] for x in sorted(in_win, key=lambda x: x["finished"])][:EVIDENCE_MAX],
|
|
524
|
+
"lead_time": lead_rows[:EVIDENCE_MAX], "lead_time_total": len(lead_rows),
|
|
525
|
+
"failed": failed_rows[:EVIDENCE_MAX], "recovery": restore_rows[:EVIDENCE_MAX]}
|
|
526
|
+
|
|
527
|
+
# Each number's likely range, so a tier is claimed only as far as the sample supports it.
|
|
528
|
+
lead_rng, restore_rng = quantile_range(lead_minutes, 0.5), quantile_range(restore, 0.5)
|
|
529
|
+
cfr_rng = [round(x, 4) for x in wilson(len(failed), len(in_win))] if cfr is not None else None
|
|
530
|
+
rec_cuts = (60, 24 * 60, 7 * 24 * 60)
|
|
531
|
+
return {"report": report, "deployments": len(in_win), "deployment_days": sum(weeks.values()),
|
|
532
|
+
"deployment_days_per_week": per_week, "deployment_days_per_month": per_month,
|
|
533
|
+
"deployment_days_per_six_months": per_six, "frequency_tier": freq, "monthly": monthly,
|
|
534
|
+
"lead_time_minutes": lead, "lead_time_hours": hours(lead), "lead_time_n": len(lead_minutes),
|
|
535
|
+
"lead_time_tier": _tier(lead, lead_cuts),
|
|
536
|
+
"lead_time_range_hours": [hours(x) for x in lead_rng] if lead_rng else None,
|
|
537
|
+
"lead_time_tier_span": _span(lead_rng, lead_cuts),
|
|
538
|
+
"change_failure_rate": cfr, "failed_deployments": len(failed),
|
|
539
|
+
"cfr_tier": _tier(cfr, cfr_cuts, inclusive=True), "cfr_range": cfr_rng,
|
|
540
|
+
"cfr_tier_span": _span(cfr_rng, cfr_cuts, inclusive=True), "cfr_fix_chain": cfr_chain, "linkage": linkage,
|
|
541
|
+
"recovery_minutes": recovery, "recovery_hours": hours(recovery), "recovery_n": len(restore),
|
|
542
|
+
"mttr_minutes": mttr, "mttr_hours": hours(mttr),
|
|
543
|
+
"recovery_range_hours": [hours(x) for x in restore_rng] if restore_rng and report != "2021" else None,
|
|
544
|
+
"recovery_tier": _tier(rec_value, rec_cuts),
|
|
545
|
+
"recovery_tier_span": _span(restore_rng, rec_cuts) if report != "2021" else None, "evidence": evidence}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def devlake_pr_cycle(rows: list[tuple], start: datetime, end: datetime) -> list[dict]:
|
|
549
|
+
"""DevLake's "PR Cycle Time [Merged PRs]" panel: mean cycle hours by month merged, for PRs merged in the range and
|
|
550
|
+
opened from the first day of the month after the range starts."""
|
|
551
|
+
s, e = iso(start), iso(end)
|
|
552
|
+
first = (start.date().replace(day=1) + timedelta(days=32)).replace(day=1).isoformat()
|
|
553
|
+
months = defaultdict(list)
|
|
554
|
+
for p, ph, _dep in rows:
|
|
555
|
+
if s <= p["mg"] <= e and p["cr"] >= first:
|
|
556
|
+
months[p["mg"][:7]].append((ph["devlake_cycle_min"] or 0) / 60)
|
|
557
|
+
return [{"month": f"{m}-01", "avg_hours": sum(v) / len(v), "n": len(v)} for m, v in sorted(months.items())]
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _band(name: str, p75: float | None) -> str | None:
|
|
561
|
+
cuts = LINEARB_BANDS.get(name)
|
|
562
|
+
if not cuts or p75 is None:
|
|
563
|
+
return None
|
|
564
|
+
return "elite" if p75 < cuts[0] else "good" if p75 <= cuts[1] else "fair" if p75 <= cuts[2] else "needs improvement"
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _summary(name: str, values: list[float], prs: list | None = None) -> dict:
|
|
568
|
+
# With the PR numbers, the phase also names its pull requests, longest first, so a P75 can be opened up.
|
|
569
|
+
proof = {"evidence": sorted(([n, round(v, 2)] for n, v in zip(prs, values)), key=lambda r: -r[1])[:PHASE_EVIDENCE_MAX]} if prs else {}
|
|
570
|
+
if len(values) < MIN_PRS:
|
|
571
|
+
return {"n": len(values), "enough": False, "p50_h": None, "p75_h": None, "low_h": None, "high_h": None, "linearb_band": None, **proof}
|
|
572
|
+
rng = quantile_range(values, 0.5)
|
|
573
|
+
p50, p75 = median(values), percentile(values, 0.75)
|
|
574
|
+
return {"n": len(values), "enough": True, "p50_h": round(p50, 2), "p75_h": round(p75, 2),
|
|
575
|
+
"low_h": round(rng[0], 2) if rng else None, "high_h": round(rng[1], 2) if rng else None,
|
|
576
|
+
"linearb_band": _band(name, p75), **proof}
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _stuck(open_prs: list[dict], now: datetime) -> dict:
|
|
580
|
+
"""LinearB's saved filters for open PRs with published cut-offs, plus one of ours: red checks nobody acted on."""
|
|
581
|
+
from stackvitals.flow.analyze import ready_at, who_acts_next
|
|
582
|
+
out = {"hanging_review": [], "long_lived": [], "high_interaction": [], "red_and_idle": []}
|
|
583
|
+
end = iso(now)
|
|
584
|
+
for p in open_prs:
|
|
585
|
+
if p.get("draft") or kind_of(p.get("au"), p.get("at")) != "person":
|
|
586
|
+
continue
|
|
587
|
+
au, ready = p.get("au"), ready_at(p) or p["cr"]
|
|
588
|
+
reviews = [e for e in p.get("ev") or [] if e[0] == "review" and len(e) > 5 and _person(e[2], e[5], au)]
|
|
589
|
+
asked = [e[1] for e in p.get("ev") or [] if e[0] == "req" and len(e) > 2 and e[2] in (p.get("rq") or [])]
|
|
590
|
+
if asked:
|
|
591
|
+
last = max(asked)
|
|
592
|
+
waited = _hours(last, end) or 0
|
|
593
|
+
if waited > HANGING_HOURS and not any(e[1] >= last for e in reviews):
|
|
594
|
+
out["hanging_review"].append({"pr": p["n"], "since": last, "hours": round(waited, 1)})
|
|
595
|
+
open_h = _hours(ready, end) or 0
|
|
596
|
+
if open_h > LONG_LIVED_HOURS:
|
|
597
|
+
out["long_lived"].append({"pr": p["n"], "since": ready, "hours": round(open_h, 1)})
|
|
598
|
+
if (p.get("cmn") or 0) >= BUSY_COMMENTS or len(reviews) >= BUSY_COMMENTS:
|
|
599
|
+
out["high_interaction"].append({"pr": p["n"], "comments": p.get("cmn") or 0, "reviews": len(reviews)})
|
|
600
|
+
w = who_acts_next(p, now)
|
|
601
|
+
if w.get("actor") == "author" and w.get("step") == "checks" and (_hours(w.get("since"), end) or 0) > 24:
|
|
602
|
+
out["red_and_idle"].append({"pr": p["n"], "since": w["since"], "why": w["why"]})
|
|
603
|
+
for key in out:
|
|
604
|
+
out[key].sort(key=lambda x: x["pr"])
|
|
605
|
+
return out
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def apply_identity(flow: dict, ident: dict | None) -> dict:
|
|
609
|
+
"""The PRs with old logins renamed to current ones and account types set by the repo's identity settings."""
|
|
610
|
+
ident = ident or {}
|
|
611
|
+
aliases = {k.lower(): v for k, v in (ident.get("aliases") or {}).items()}
|
|
612
|
+
bots, people = ({x.lower() for x in ident.get(k) or []} for k in ("bots", "people"))
|
|
613
|
+
if not (aliases or bots or people):
|
|
614
|
+
return flow
|
|
615
|
+
|
|
616
|
+
def who(login):
|
|
617
|
+
return aliases.get((login or "").lower(), login)
|
|
618
|
+
|
|
619
|
+
def typ(original, login, given):
|
|
620
|
+
names = {(original or "").lower(), (login or "").lower()}
|
|
621
|
+
return "Bot" if names & bots else "Person" if names & people else given
|
|
622
|
+
|
|
623
|
+
def fix(p):
|
|
624
|
+
q = {**p, "au": who(p.get("au")), "rq": [who(x) for x in p.get("rq") or []]}
|
|
625
|
+
q["at"] = typ(p.get("au"), q["au"], p.get("at"))
|
|
626
|
+
q["ev"] = [[*e[:2], who(e[2]), e[3], e[4], typ(e[2], who(e[2]), e[5]), *e[6:]] if e[0] == "review" and len(e) > 5
|
|
627
|
+
else [*e[:2], who(e[2]), *e[3:]] if len(e) > 2 else list(e) for e in p.get("ev") or []]
|
|
628
|
+
q["cm"] = [[c[0], who(c[1]), c[2], typ(c[1], who(c[1]), c[3]), *c[4:]] if len(c) > 3 else list(c)
|
|
629
|
+
for c in p.get("cm") or []]
|
|
630
|
+
return q
|
|
631
|
+
return {**flow, **{k: [fix(p) for p in flow.get(k) or []] for k in ("closed", "open")}}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _group(rows: list[tuple]) -> dict:
|
|
635
|
+
return {"n": len(rows), **{name: _summary(name, [r[1][name] for r in rows if r[1][name] is not None])
|
|
636
|
+
for name in ("cycle_to_merge", "pickup", "review")}}
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def by_team(rows: list[tuple], ident: dict | None, shipped_rows: list[tuple] | None = None, incidents: list[dict] | None = None,
|
|
640
|
+
start: datetime | None = None, end: datetime | None = None, report: str = "2023") -> dict:
|
|
641
|
+
"""Cycle time for each team in the identity settings, by its members' merged PRs; with the shipped rows, DORA too,
|
|
642
|
+
from the deployments that shipped the team's PRs (DevLake's DORA by team)."""
|
|
643
|
+
teams = (ident or {}).get("teams") or {}
|
|
644
|
+
aliases = {k.lower(): v for k, v in ((ident or {}).get("aliases") or {}).items()}
|
|
645
|
+
members = {team: {aliases.get(x.lower(), x).lower() for x in logins} for team, logins in teams.items()}
|
|
646
|
+
out = {}
|
|
647
|
+
for team, logins in members.items():
|
|
648
|
+
entry = _group([r for r in rows if (r[0].get("au") or "").lower() in logins])
|
|
649
|
+
if shipped_rows is not None and start and end:
|
|
650
|
+
mine = [r for r in shipped_rows if (r[0].get("au") or "").lower() in logins and r[2]]
|
|
651
|
+
own = list({r[2]["id"]: r[2] for r in mine}.values())
|
|
652
|
+
ids = {d["id"] for d in own}
|
|
653
|
+
if own:
|
|
654
|
+
deployed = [{"deployment": dep["id"], "cycle_min": ph["devlake_cycle_min"], "pr": p.get("n")} for p, ph, dep in mine]
|
|
655
|
+
entry["dora"] = {k: v for k, v in dora(own, deployed, [i for i in incidents or [] if i.get("deployment") in ids],
|
|
656
|
+
start, end, report).items() if k != "evidence"}
|
|
657
|
+
out[team] = entry
|
|
658
|
+
return out
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def by_service(rows: list[tuple], deps: list[dict], incidents: list[dict], cfg: dict, start: datetime, end: datetime,
|
|
662
|
+
report: str = "2023", kept: list[tuple] | None = None) -> dict:
|
|
663
|
+
"""Each [services] entry: its merged PRs (any changed path matching its patterns; one touching two services counts in
|
|
664
|
+
both) and their review phases, plus DORA from its own deployments when a deploy pattern tells them apart."""
|
|
665
|
+
from stackvitals.codeowners import matches
|
|
666
|
+
out = {}
|
|
667
|
+
for name, svc in (cfg.get("services") or {}).items():
|
|
668
|
+
def ours(rs):
|
|
669
|
+
return [r for r in rs if any(matches(pat, path) for path in r[0].get("paths") or [] for pat in svc["paths"])]
|
|
670
|
+
mine = ours(rows)
|
|
671
|
+
# Review phases from the same PRs as the repo's (outliers left out); DORA from every merged one, as the repo's.
|
|
672
|
+
phased = ours(kept) if kept is not None else mine
|
|
673
|
+
entry = {"prs": len(phased), **_group(phased)}
|
|
674
|
+
if svc.get("deploy"):
|
|
675
|
+
pattern = re.compile(svc["deploy"])
|
|
676
|
+
own = [d for d in deps if pattern.search(d.get("name") or "")]
|
|
677
|
+
ids = {d["id"] for d in own}
|
|
678
|
+
shipped_by = [(p, ph, deployment_of(p, own)) for p, ph, _dep in mine] if own else []
|
|
679
|
+
deployed = [{"deployment": dep["id"], "cycle_min": ph["devlake_cycle_min"], "pr": p.get("n")}
|
|
680
|
+
for p, ph, dep in shipped_by if dep]
|
|
681
|
+
s, e = iso(start), iso(end)
|
|
682
|
+
entry["deployments"] = sum(1 for d in own if s <= d["finished"] <= e)
|
|
683
|
+
entry["dora"] = {k: v for k, v in dora(own, deployed, [i for i in incidents if i.get("deployment") in ids],
|
|
684
|
+
start, end, report).items() if k != "evidence"}
|
|
685
|
+
out[name] = entry
|
|
686
|
+
return out
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def by_owner(rows: list[tuple], codeowners: str | None) -> dict:
|
|
690
|
+
"""Cycle time for each owning team in CODEOWNERS, by the merged PRs touching its paths; single people are left out."""
|
|
691
|
+
if not codeowners:
|
|
692
|
+
return {}
|
|
693
|
+
from stackvitals.flow.knowledge import codeowners_for
|
|
694
|
+
groups = defaultdict(list)
|
|
695
|
+
for r in rows:
|
|
696
|
+
owners = {o for path in r[0].get("paths") or [] for o in codeowners_for(path, codeowners) if "/" in o}
|
|
697
|
+
for owner in owners:
|
|
698
|
+
groups[f"@{owner}"].append(r)
|
|
699
|
+
return {owner: _group(rs) for owner, rs in sorted(groups.items())}
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _on_production(p: dict, prod_branches: list[str], in_ranges: set) -> bool:
|
|
703
|
+
from fnmatch import fnmatch
|
|
704
|
+
return any(fnmatch(p.get("base") or "", pat) for pat in prod_branches) or (p.get("mc") in in_ranges)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def shipped(flow: dict, repo: dict, ci: dict, cfg: dict, kinds=("person",)) -> tuple[list[dict], str | None, list[tuple]]:
|
|
708
|
+
"""Deployments, where they came from, and (PR, phases, deployment) for each PR by `kinds` merged into the default branch,
|
|
709
|
+
a production branch, or any branch whose merge commit a deployment shipped (develop merged into main later)."""
|
|
710
|
+
deps, source = find_deployments(repo, ci or {}, cfg, flow.get("closed") or [])
|
|
711
|
+
default = repo.get("default_branch")
|
|
712
|
+
prod_branches = cfg["delivery"].get("production_branches") or []
|
|
713
|
+
in_ranges = {sha for d in deps for sha in d.get("shipped") or ()}
|
|
714
|
+
rows = []
|
|
715
|
+
for p in flow.get("closed") or []:
|
|
716
|
+
if not p.get("mg"):
|
|
717
|
+
continue
|
|
718
|
+
if default and p.get("base") and p.get("base") != default and not _on_production(p, prod_branches, in_ranges):
|
|
719
|
+
continue
|
|
720
|
+
if kind_of(p.get("au"), p.get("at")) not in kinds:
|
|
721
|
+
continue
|
|
722
|
+
dep = deployment_of(p, deps) if deps else None
|
|
723
|
+
rows.append((p, phases(p, dep["finished"] if dep else None, cfg.get("calendar")), dep))
|
|
724
|
+
return deps, source, rows
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _logins(kept: list, ident: dict | None) -> set[str]:
|
|
728
|
+
"""The people an assignee could be: authors of the merged PRs and everyone named in the identity settings."""
|
|
729
|
+
return ({r[0].get("au") for r in kept if r[0].get("au")} | {x for xs in ((ident or {}).get("teams") or {}).values() for x in xs}
|
|
730
|
+
| set((ident or {}).get("people") or []))
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _tracker(tracker: dict | None, kept: list, now: datetime, window_days: int, ident: dict | None = None) -> dict | None:
|
|
734
|
+
"""Issues from the connected trackers: merged PRs linked to them, lead and cycle time by type, and the last sprints."""
|
|
735
|
+
# Jira or Linear only: a pager tool's incidents count in DORA, not as issues and sprints.
|
|
736
|
+
if not tracker or not set(tracker["sources"]) & set(TRACKER_NAMES):
|
|
737
|
+
return None
|
|
738
|
+
issues = [i for i in tracker["issues"] if i.get("src") not in PAGERS]
|
|
739
|
+
moves = tracker["changes"]
|
|
740
|
+
since = iso(now - timedelta(days=window_days))
|
|
741
|
+
linked = {n for n, _key in tracked.pr_links([r[0] for r in kept], issues)}
|
|
742
|
+
return {"sources": tracker["sources"], "issues": len(issues), "linked_prs": len(linked), "merged_prs": len(kept),
|
|
743
|
+
"lead_time": tracked.lead_times(issues, moves, now - timedelta(days=window_days), now),
|
|
744
|
+
"planning": tracked.planning(issues, moves, tracker["sprints"], now, since=now - timedelta(days=BACKFILL_DAYS)),
|
|
745
|
+
"incidents": sum(1 for i in issues if i.get("incident") and (i.get("created") or "") >= since),
|
|
746
|
+
# Issues done per team, through [identity] aliases of each assignee; private repos only keep assignees.
|
|
747
|
+
"by_team": identity.issues_by_team(issues, ident, since, _logins(kept, ident)) if any(i.get("assignee") for i in issues) else None,
|
|
748
|
+
# Done issues by the tracker's own team field ([connectors.jira] team_field), when one is mapped.
|
|
749
|
+
"by_field_team": dict(Counter(i["team"] for i in issues if i.get("team") and i.get("cat") == "DONE"
|
|
750
|
+
and (i.get("resolved") or "") >= since).most_common(20)) or None}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _changes(rows: list, deps: list[dict], incidents: list[dict], now: datetime) -> list[dict]:
|
|
754
|
+
"""This week's review phases, deployments and change failure rate against the four weeks before, plus the review
|
|
755
|
+
phases over the last 14 days (a slowdown that lasts), each judged against how much this repo's weeks usually swing."""
|
|
756
|
+
return changes.calibrate(lambda t: _week_checks(rows, deps, incidents, t), now)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _week_checks(rows: list, deps: list[dict], incidents: list[dict], now: datetime) -> list[dict]:
|
|
760
|
+
(ws, we), (bs, be) = changes.windows(now)
|
|
761
|
+
kept = [r for r in rows if r[0].get("mg") and (r[1]["cycle_to_merge"] or 0) <= OUTLIER_HOURS]
|
|
762
|
+
phase = lambda name, a, b: [r[1][name] for r in kept if a <= r[0]["mg"] < b] # noqa: E731
|
|
763
|
+
names = (("pickup", "pickup"), ("cycle_to_merge", "opened to merged"))
|
|
764
|
+
out = [changes.times(f"delivery.{name}", label, phase(name, ws, we), phase(name, bs, be), better=-1, unit="h")
|
|
765
|
+
# Review alone is left out: it is near zero for most PRs (approval, then merge) and inside opened to merged.
|
|
766
|
+
for name, label in names]
|
|
767
|
+
shipped = lambda a, b: [d["id"] for d in deps if a <= d["finished"] < b] # noqa: E731
|
|
768
|
+
now_ids, base_ids = shipped(ws, we), shipped(bs, be)
|
|
769
|
+
failed = {i.get("deployment") for i in incidents}
|
|
770
|
+
out.append(changes.count("delivery.deployments", "deployments", len(now_ids), len(base_ids), better=1))
|
|
771
|
+
out.append(changes.share("delivery.change_failure", "deployments that failed", sum(d in failed for d in now_ids),
|
|
772
|
+
len(now_ids), sum(d in failed for d in base_ids), len(base_ids), better=-1))
|
|
773
|
+
# A slowdown that lasts: the last 14 days against the four weeks before them (2x the catches in its second week).
|
|
774
|
+
(ts, te), (tbs, tbe) = changes.windows(now, 2)
|
|
775
|
+
out += [changes.times(f"delivery.{name}_14d", f"{label} over the last 14 days", phase(name, ts, te),
|
|
776
|
+
phase(name, tbs, tbe), better=-1, unit="h", span=2) for name, label in names]
|
|
777
|
+
return out
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def dora_rows(deps: list[dict], deployed: list[dict], incidents: list[dict], now: datetime, days: int = 365,
|
|
781
|
+
most: int = 2500) -> dict:
|
|
782
|
+
"""Compact rows for DORA over any date range, computed in the app: each deployment [finished, failed, recovery
|
|
783
|
+
hours] and each shipped PR [its deployment's finish, lead time in hours], for the last `days`."""
|
|
784
|
+
since = iso(now - timedelta(days=days))
|
|
785
|
+
failed = defaultdict(list)
|
|
786
|
+
for i in incidents:
|
|
787
|
+
if i.get("deployment"):
|
|
788
|
+
failed[i["deployment"]].append(i.get("hours_to_restore"))
|
|
789
|
+
finished = {d["id"]: d["finished"] for d in deps}
|
|
790
|
+
rows = [[d["finished"], d["id"] in failed, min((h for h in failed[d["id"]] if h is not None), default=None)]
|
|
791
|
+
for d in deps if d["finished"] >= since]
|
|
792
|
+
prs = [[finished[x["deployment"]], round(x["cycle_min"] / 60, 2)] for x in deployed
|
|
793
|
+
if x.get("cycle_min") is not None and finished.get(x["deployment"], "") >= since]
|
|
794
|
+
return {"deployments": sorted(rows)[-most:], "prs": sorted(prs)[-most:]}
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def dora_weeks(deps: list[dict], deployed: list[dict], incidents: list[dict], now: datetime, weeks: int = 13,
|
|
798
|
+
report: str = "2023") -> list[dict]:
|
|
799
|
+
"""All four DORA numbers week by week (Monday to Monday), oldest first, counted as the window's are."""
|
|
800
|
+
monday = (now - timedelta(days=now.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
801
|
+
out = []
|
|
802
|
+
for k in range(weeks - 1, -1, -1):
|
|
803
|
+
start, end = monday - timedelta(weeks=k), min(monday - timedelta(weeks=k - 1), now)
|
|
804
|
+
got = dora(deps, deployed, incidents, start, end, report)
|
|
805
|
+
out.append({"week": start.date().isoformat(), "deployments": got["deployments"], "lead_time_hours": got["lead_time_hours"],
|
|
806
|
+
"change_failure_rate": got["change_failure_rate"], "recovery_hours": got["recovery_hours"]})
|
|
807
|
+
return out
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def deployment_list(merged: list[tuple], deps: list[dict], incidents: list[dict], since: str, most: int = 30) -> list[dict]:
|
|
811
|
+
"""The window's deployments, newest first, each with what it shipped (people's and agents' PRs, their authors, the
|
|
812
|
+
median lead time) and the incidents blamed on it (Middleware's deployment drill-down)."""
|
|
813
|
+
shipped_by = defaultdict(list)
|
|
814
|
+
for p, ph, dep in merged:
|
|
815
|
+
if dep:
|
|
816
|
+
shipped_by[dep["id"]].append((p, ph))
|
|
817
|
+
blamed = defaultdict(list)
|
|
818
|
+
for i in incidents:
|
|
819
|
+
if i.get("deployment"):
|
|
820
|
+
blamed[i["deployment"]].append(i.get("number"))
|
|
821
|
+
out = []
|
|
822
|
+
for d in sorted((d for d in deps if d["finished"] >= since), key=lambda d: d["finished"], reverse=True)[:most]:
|
|
823
|
+
prs = shipped_by.get(d["id"], [])
|
|
824
|
+
leads = sorted(ph["devlake_cycle_min"] for _p, ph in prs if ph.get("devlake_cycle_min") is not None)
|
|
825
|
+
out.append({"id": d["id"], "name": d.get("name"), "sha": d.get("sha"), "finished": d["finished"],
|
|
826
|
+
"prs": sorted(p["n"] for p, _ph in prs), "authors": len({p.get("au") for p, _ph in prs if p.get("au")}),
|
|
827
|
+
"lead_hours": round(leads[len(leads) // 2] / 60, 2) if leads else None, "incidents": blamed.get(d["id"], [])})
|
|
828
|
+
return out
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def build(flow: dict, repo: dict, ci: dict, issues: list[dict], now: datetime, window_days: int, cfg: dict,
|
|
832
|
+
tracker: dict | None = None) -> dict:
|
|
833
|
+
"""Everything the Delivery page shows, for the last `window_days`; `tracker` is what the connectors pulled (Jira,
|
|
834
|
+
Linear), or None."""
|
|
835
|
+
since = iso(now - timedelta(days=window_days))
|
|
836
|
+
ident = cfg.get("identity")
|
|
837
|
+
flow = apply_identity(flow, ident)
|
|
838
|
+
deps, source, merged = shipped(flow, repo, ci, cfg, ("person", "agent"))
|
|
839
|
+
# Cycle time and DORA are about people's PRs; coding agents' PRs join only the AI-assisted outcomes and rework.
|
|
840
|
+
rows = [r for r in merged if kind_of(r[0].get("au"), r[0].get("at")) == "person"]
|
|
841
|
+
closed = flow.get("closed") or []
|
|
842
|
+
deployed = [{"deployment": dep["id"], "cycle_min": ph["devlake_cycle_min"], "pr": p.get("n")} for p, ph, dep in rows if dep]
|
|
843
|
+
window = [r for r in rows if r[0]["mg"] >= since]
|
|
844
|
+
over60 = [r for r in window if (r[1]["cycle_to_merge"] or 0) > OUTLIER_HOURS]
|
|
845
|
+
kept = [r for r in window if r not in over60]
|
|
846
|
+
over98 = []
|
|
847
|
+
if len(kept) >= OUTLIER_MIN_PRS:
|
|
848
|
+
cut = percentile([r[1]["cycle_to_merge"] for r in kept if r[1]["cycle_to_merge"] is not None], 0.98)
|
|
849
|
+
over98 = [r for r in kept if (r[1]["cycle_to_merge"] or 0) > cut]
|
|
850
|
+
kept = [r for r in kept if r not in over98]
|
|
851
|
+
out_phases = {name: _summary(name, [r[1][name] for r in kept if r[1][name] is not None],
|
|
852
|
+
[r[0].get("n") for r in kept if r[1][name] is not None]) for name in PHASES}
|
|
853
|
+
incidents, incident_source = incidents_for(closed + (flow.get("open") or []), issues, deps, cfg, tracker, repo.get("recorded"))
|
|
854
|
+
report = cfg["delivery"]["dora_report"]
|
|
855
|
+
sizes = [(r[0].get("add") or 0) + (r[0].get("del") or 0) for r in kept]
|
|
856
|
+
authors = {r[0].get("au") for r in kept}
|
|
857
|
+
weeks = max(window_days / 7, 1)
|
|
858
|
+
talk = [sum(1 for c in r[0].get("cm") or [] if len(c) > 3 and _person(c[1], c[3], r[0].get("au")))
|
|
859
|
+
+ sum(1 for e in r[0].get("ev") or [] if e[0] == "review" and len(e) > 5 and _person(e[2], e[5], r[0].get("au")))
|
|
860
|
+
for r in kept]
|
|
861
|
+
unreviewed = [r[0]["n"] for r in kept if not r[1]["reviewed"]]
|
|
862
|
+
lightning = [r for r in kept if r[1]["reviewed"] and r[1]["review"] is not None and r[1]["review"] < LIGHTNING_HOURS]
|
|
863
|
+
agents = [p for p in closed if kind_of(p.get("au"), p.get("at")) == "agent" and (p.get("cl") or "") >= since]
|
|
864
|
+
assisted = [r for r in kept if assisted_by(r[0])]
|
|
865
|
+
others = [r for r in kept if not assisted_by(r[0])]
|
|
866
|
+
|
|
867
|
+
def p75(values):
|
|
868
|
+
v = percentile([x for x in values if x is not None], 0.75)
|
|
869
|
+
return None if v is None else round(v, 2)
|
|
870
|
+
|
|
871
|
+
def ai_reviewed(p):
|
|
872
|
+
return any(e[0] == "review" and len(e) > 2 and (e[2] or "").lower().removesuffix("[bot]") in AI_REVIEWERS
|
|
873
|
+
for e in p.get("ev") or [])
|
|
874
|
+
from stackvitals.core import agreements
|
|
875
|
+
agreed = agreements.check(rows, flow.get("open") or [], cfg.get("agreements") or {}, now)
|
|
876
|
+
keys = dora(deps, deployed, incidents, now - timedelta(days=window_days), now, report)
|
|
877
|
+
from stackvitals.core.calendar import is_set
|
|
878
|
+
if is_set(cfg.get("calendar")):
|
|
879
|
+
# Beside DevLake's calendar-time lead time: the same deployed PRs in working time (weekends and holidays out).
|
|
880
|
+
work = [r[1]["cycle"] for r in rows if r[2] and r[1].get("cycle") is not None
|
|
881
|
+
and iso(now - timedelta(days=window_days)) <= r[2]["finished"] <= iso(now)]
|
|
882
|
+
keys["lead_time_working_hours"] = round(median(work), 2) if work else None
|
|
883
|
+
# DORA's fifth metric (instability): deployments that shipped an unplanned fix, over all deployments in the window. The fixes are
|
|
884
|
+
# GitHub's (reverts, hotfixes, PRs naming an incident issue) even when a pager tool's incidents count for failures.
|
|
885
|
+
fixes = find_incidents(closed + (flow.get("open") or []), issues, deps, cfg)
|
|
886
|
+
keys.update(rework_rate(deps, merged, fixes, now - timedelta(days=window_days), now))
|
|
887
|
+
half_year = [r for r in merged if (r[0].get("mg") or "") >= iso(now - timedelta(days=183))]
|
|
888
|
+
return {"generated": iso(now), "window_days": window_days, "since": since, "prs": len(kept), "agreements": agreed,
|
|
889
|
+
"deploy_source": source, "deploy_confidence": CONFIDENCE.get(source), "incident_source": incident_source,
|
|
890
|
+
# Every deploy attempt, failed and cancelled ones too: success rate and how long deploys take.
|
|
891
|
+
"deploy_attempts": deploy_attempts(repo, ci or {}, cfg, source, now - timedelta(days=window_days), now),
|
|
892
|
+
# Which deployments count and why the rest don't, so a team can check the number before trusting it.
|
|
893
|
+
"deploy_check": explain_deployments(repo, ci or {}, cfg),
|
|
894
|
+
"deployments_unreadable": bool(repo.get("no_deployments")),
|
|
895
|
+
"deploy_names": [{"name": n, "n": k} for n, k in Counter(
|
|
896
|
+
x.get("name") for x in deps if x["finished"] >= since).most_common(5)] if source != "releases" else [],
|
|
897
|
+
"deployments": [{k: x[k] for k in ("id", "sha", "finished", "name")} for x in deps[-20:]],
|
|
898
|
+
"phases": out_phases, "outliers": {"over_60_days": len(over60), "over_p98": len(over98)},
|
|
899
|
+
"dora": keys, "changes": changes.annotate(_changes(rows, deps, incidents, now)),
|
|
900
|
+
"devlake_pr_cycle": devlake_pr_cycle(rows, now - timedelta(days=window_days), now),
|
|
901
|
+
"incidents": [i for i in incidents if i["created"] >= since][-20:],
|
|
902
|
+
# The four DORA numbers week by week, to see a trend rather than one window.
|
|
903
|
+
"dora_weeks": dora_weeks(deps, deployed, incidents, now, report=report),
|
|
904
|
+
# The rows the app computes DORA from for any date range in the last year.
|
|
905
|
+
"dora_rows": dora_rows(deps, deployed, incidents, now),
|
|
906
|
+
# Each recent deployment with what it shipped and the incidents blamed on it.
|
|
907
|
+
"deployment_list": deployment_list(merged, deps, incidents, since),
|
|
908
|
+
# For each recent incident: the deployments and pull requests before it began, likeliest cause first.
|
|
909
|
+
"what_changed": what_changed.recent(incidents, rows, deps, now),
|
|
910
|
+
"sizes": {"n": len(sizes), "p50": median(sizes), "p75": percentile(sizes, 0.75),
|
|
911
|
+
"under_100": sum(1 for x in sizes if x < SIZE_EDGES[0]),
|
|
912
|
+
"100_155": sum(1 for x in sizes if SIZE_EDGES[0] <= x <= SIZE_EDGES[1]),
|
|
913
|
+
"156_228": sum(1 for x in sizes if SIZE_EDGES[1] < x <= SIZE_EDGES[2]),
|
|
914
|
+
"over_228": sum(1 for x in sizes if x > SIZE_EDGES[2])},
|
|
915
|
+
"merge_frequency": round(len(kept) / max(len(authors), 1) / weeks, 2) if kept else None,
|
|
916
|
+
"review_depth": round(sum(talk) / len(talk), 2) if talk else None,
|
|
917
|
+
"merged_without_review": {"n": len(unreviewed), "share": round(len(unreviewed) / len(kept), 4) if kept else None},
|
|
918
|
+
"merged_flags": {"lightning": [{"pr": r[0]["n"], "review_minutes": round(r[1]["review"] * 60)} for r in lightning],
|
|
919
|
+
"without_review": [{"pr": n} for n in unreviewed[:20]]},
|
|
920
|
+
"stuck": _stuck(flow.get("open") or [], now),
|
|
921
|
+
# Which open PRs look risky by this repo's own history, shown only where its backtest says it works.
|
|
922
|
+
"change_risk": change_risk.assess(closed, change_risk.troubled(rows, incidents) | change_risk.follow_up_fixes(closed),
|
|
923
|
+
[p for p in flow.get("open") or [] if not p.get("draft")]),
|
|
924
|
+
"by_team": by_team(kept, ident, rows, incidents, now - timedelta(days=window_days), now, report), "by_owner": by_owner(kept, repo.get("codeowners")),
|
|
925
|
+
"by_service": by_service(rows, deps, incidents, cfg, now - timedelta(days=window_days), now, report, kept) or None,
|
|
926
|
+
"tracker": _tracker(tracker, kept, now, window_days, ident),
|
|
927
|
+
# What each recent release shipped and what followed it (reverts of its PRs, incidents in two weeks).
|
|
928
|
+
"releases": releases.quality(closed, repo.get("releases") or [], incidents, now),
|
|
929
|
+
# Bugs opened and closed each week, and how old the open ones are (GitHub bug labels, Jira and Linear bugs).
|
|
930
|
+
"bugs": bugs.backlog(issues, [i for i in (tracker or {}).get("issues") or [] if i.get("src") not in PAGERS], now),
|
|
931
|
+
# Where the work goes, from labels, linked issue types, titles and paths (people's and agents' PRs).
|
|
932
|
+
"investment": investment.mix(closed, now, (cfg.get("investment") or {}).get("labels"),
|
|
933
|
+
investment.tracker_types(closed, tracker["issues"]) if tracker else None,
|
|
934
|
+
base=repo.get("default_branch")),
|
|
935
|
+
# With Jira or Linear connected: the same work by epic or project, and the feature share teams capitalise.
|
|
936
|
+
"epics": investment.by_epic(closed, tracker["issues"], now, base=repo.get("default_branch")) if tracker else None,
|
|
937
|
+
"ai": {"agent_prs": {"n": len(agents), "merged": sum(1 for p in agents if p.get("mg")),
|
|
938
|
+
"merged_share": round(sum(1 for p in agents if p.get("mg")) / len(agents), 4) if agents else None},
|
|
939
|
+
"assisted_prs": {"n": len(assisted), "share": round(len(assisted) / len(kept), 4) if kept else None,
|
|
940
|
+
"tools": dict(Counter(t for r in assisted for t in assisted_by(r[0]))),
|
|
941
|
+
"pickup_p75_h": p75(r[1]["pickup"] for r in assisted),
|
|
942
|
+
"others_pickup_p75_h": p75(r[1]["pickup"] for r in others),
|
|
943
|
+
"size_p75": p75((r[0].get("add") or 0) + (r[0].get("del") or 0) for r in assisted),
|
|
944
|
+
"others_size_p75": p75((r[0].get("add") or 0) + (r[0].get("del") or 0) for r in others)},
|
|
945
|
+
"ai_reviewed_prs": {"n": sum(1 for r in kept if ai_reviewed(r[0]))},
|
|
946
|
+
# The last six months: what happened after AI-assisted and other PRs merged.
|
|
947
|
+
"outcomes": outcomes(half_year, ci, incidents, now, decided=[
|
|
948
|
+
p for p in closed if kind_of(p.get("au"), p.get("at")) in ("person", "agent")]),
|
|
949
|
+
# Each coding agent on its own, the review load agents add, and what an open PR can expect.
|
|
950
|
+
"by_agent": agent_ledger.by_agent(half_year, ci, incidents, now, decided=[
|
|
951
|
+
p for p in closed if kind_of(p.get("au"), p.get("at")) in ("person", "agent")]),
|
|
952
|
+
"review_load": agent_ledger.review_load(flow, now),
|
|
953
|
+
"expected_review": agent_ledger.expected_review(
|
|
954
|
+
[p for p in flow.get("open") or [] if not p.get("draft")][:300], kept)}}
|