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,98 @@
|
|
|
1
|
+
"""Where a repo's delivery stands among similar public repos: its own P75s measured with exactly the crawl benchmarks'
|
|
2
|
+
definitions (people's PRs opened in the last 90 days; pickup from opening to the first maintainer review), placed in the
|
|
3
|
+
closest group with enough repos, with the median's range, LinearB's band shares, and what the comparison can't see."""
|
|
4
|
+
import math
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from mergescout.bench.ci import tier
|
|
9
|
+
from stackvitals.ci import bench as ci_bench
|
|
10
|
+
from stackvitals.ci.bench import RANKS, _about, _label, _rank, fit
|
|
11
|
+
from stackvitals.core.actors import People
|
|
12
|
+
from stackvitals.flow.analyze import _human_reviews, _iso, _secs, is_bot
|
|
13
|
+
|
|
14
|
+
DELIVERY_FILE = "delivery-benchmarks.json"
|
|
15
|
+
MIN_PRS = 10
|
|
16
|
+
MIN_VALUES = 5
|
|
17
|
+
METRICS = [("pickup_p75_hours", "Pickup: opened to first maintainer review (P75)"),
|
|
18
|
+
("review_p75_hours", "Review: first review to merge (P75)"),
|
|
19
|
+
("cycle_to_merge_p75_hours", "Opened to merged (P75)")]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _p75(values: list[float]) -> float | None:
|
|
23
|
+
if len(values) < MIN_VALUES:
|
|
24
|
+
return None
|
|
25
|
+
s = sorted(values)
|
|
26
|
+
return round(s[min(len(s) - 1, max(0, math.ceil(0.75 * len(s)) - 1))], 4)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def own_metrics(prs: list[dict], now: datetime, window_days: int = 90, people: People | None = None) -> dict | None:
|
|
30
|
+
"""This repo's P75s by the crawl's rules, so the comparison is like with like."""
|
|
31
|
+
since = _iso(now - timedelta(days=window_days))
|
|
32
|
+
people = people or People(prs)
|
|
33
|
+
mine = [p for p in prs if p["cr"] >= since and not is_bot(p)]
|
|
34
|
+
if len(mine) < MIN_PRS:
|
|
35
|
+
return None
|
|
36
|
+
pickup, review, cycle = [], [], []
|
|
37
|
+
for p in mine:
|
|
38
|
+
humans = _human_reviews(p)
|
|
39
|
+
first = humans[0] if humans and people.is_insider(humans[0][2]) and humans[0][2] != p.get("au") else None
|
|
40
|
+
if first and (wait := _secs(p["cr"], first[1])) is not None:
|
|
41
|
+
pickup.append(wait / 3600)
|
|
42
|
+
if p.get("mg"):
|
|
43
|
+
if first and (wait := _secs(first[1], p["mg"])) is not None:
|
|
44
|
+
review.append(wait / 3600)
|
|
45
|
+
if (wait := _secs(p["cr"], p["mg"])) is not None:
|
|
46
|
+
cycle.append(wait / 3600)
|
|
47
|
+
return {"prs": len(mine), "pickup_p75_hours": _p75(pickup), "review_p75_hours": _p75(review),
|
|
48
|
+
"cycle_to_merge_p75_hours": _p75(cycle)}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ok(q) -> bool:
|
|
52
|
+
return isinstance(q, dict) and all(isinstance(q.get(f"p{int(r * 100)}"), (int, float)) for r in RANKS)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def valid(data) -> dict | None:
|
|
56
|
+
# A bad file is ignored, metric by metric, so it can neither break a run nor reach a page.
|
|
57
|
+
if not isinstance(data, dict) or not isinstance(data.get("groups"), dict):
|
|
58
|
+
return None
|
|
59
|
+
groups = {}
|
|
60
|
+
for key, g in data["groups"].items():
|
|
61
|
+
if isinstance(key, str) and key.count("|") == 1 and isinstance(g, dict) and isinstance(g.get("repos"), int):
|
|
62
|
+
kept = {m: g[m] for m, _ in METRICS if _ok(g.get(m))}
|
|
63
|
+
if kept:
|
|
64
|
+
groups[key] = {"repos": g["repos"], **kept}
|
|
65
|
+
return {**data, "groups": groups} if groups else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load(token: str | None, cache_dir: Path, client=None) -> dict | None:
|
|
69
|
+
return ci_bench.load(token, cache_dir, client=client, name=DELIVERY_FILE, url_env="STACKVITALS_DELIVERY_BENCH_URL",
|
|
70
|
+
validate=valid, default_url=None)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def compare(own: dict | None, bench: dict | None, language: str | None, stars: int | None) -> dict | None:
|
|
74
|
+
"""The closest group with the metric (language and size, then wider), and this repo's place in it."""
|
|
75
|
+
bench = valid(bench) if bench else None
|
|
76
|
+
if not own or not bench:
|
|
77
|
+
return None
|
|
78
|
+
groups, size = bench["groups"], tier(stars)
|
|
79
|
+
metrics, chosen = [], None
|
|
80
|
+
for name, label in METRICS:
|
|
81
|
+
if own.get(name) is None:
|
|
82
|
+
continue
|
|
83
|
+
key = next((k for k in (f"{language}|{size}", f"{language}|all", f"all|{size}", "all|all")
|
|
84
|
+
if k in groups and name in groups[k]), None)
|
|
85
|
+
if key is None:
|
|
86
|
+
continue
|
|
87
|
+
chosen = chosen or key
|
|
88
|
+
q = groups[key][name]
|
|
89
|
+
r = _rank(own[name], q)
|
|
90
|
+
place = f"slower than about {_about(r)}%" if r >= 0.5 else f"faster than about {_about(1 - r)}%"
|
|
91
|
+
metrics.append({"key": name, "name": label, "you": own[name], "typical": q["p50"], "low": q.get("p50_low"),
|
|
92
|
+
"high": q.get("p50_high"), "p25": q["p25"], "p75": q["p75"], "place": place, "repos": q["repos"],
|
|
93
|
+
"group": _label(key), "linearb": q.get("linearb")})
|
|
94
|
+
if not metrics:
|
|
95
|
+
return None
|
|
96
|
+
return {"group": _label(chosen), "repos": groups[chosen]["repos"], "built_at": bench.get("built_at"), "metrics": metrics,
|
|
97
|
+
"fit": fit(chosen, language, size),
|
|
98
|
+
"population": bench.get("population"), "gaps": bench.get("gaps")}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""The Delivery page, the job summary section and the Action step that writes them from the job's checkout."""
|
|
2
|
+
import html
|
|
3
|
+
import json
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
|
|
6
|
+
from stackvitals import shell
|
|
7
|
+
from stackvitals.ci.bench import PEERS_ARE
|
|
8
|
+
from stackvitals.core.time import iso
|
|
9
|
+
|
|
10
|
+
NAMES = {"coding": "Coding", "pickup": "Pickup", "review": "Review", "approve": "Approve", "merge": "Merge",
|
|
11
|
+
"deploy": "Deploy", "cycle": "Cycle time", "cycle_to_merge": "Cycle time to merge"}
|
|
12
|
+
HELP = {"coding": "first commit to opened", "pickup": "ready for review to the first other person's comment or review",
|
|
13
|
+
"review": "first comment or review to merge", "approve": "first comment or review to first approval",
|
|
14
|
+
"merge": "first approval to merge", "deploy": "merge to the deployment that shipped it",
|
|
15
|
+
"cycle": "coding + pickup + review + deploy", "cycle_to_merge": "coding + pickup + review"}
|
|
16
|
+
STUCK = {"hanging_review": "Hanging review (asked more than 3 days ago, no review yet)",
|
|
17
|
+
"long_lived": "Long-lived (ready for more than 30 days)", "high_interaction": "High interaction (10+ comments or reviews)",
|
|
18
|
+
"red_and_idle": "Red and idle (failing checks nobody acted on for a day)"}
|
|
19
|
+
DORA_TEXT = {"elite": "elite", "high": "high", "medium": "medium", "low": "low", None: "not enough data"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _h(hours) -> str:
|
|
23
|
+
if hours is None:
|
|
24
|
+
return "-"
|
|
25
|
+
if hours >= 48:
|
|
26
|
+
return f"{hours / 24:.1f} days"
|
|
27
|
+
return f"{hours:.1f} h" if hours >= 1 else f"{hours * 60:.0f} min"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _pct(x) -> str:
|
|
31
|
+
return "-" if x is None else f"{x:.0%}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SURE = {"high": "sure: GitHub deployments to production", "medium": "fairly sure: published versions",
|
|
35
|
+
"low": "a guess from workflow names"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
TRACKERS = {"jira": "Jira", "linear": "Linear"}
|
|
39
|
+
ISSUE_TYPES = {"REQUIREMENT": "Stories and features", "BUG": "Bugs", "TASK": "Tasks", "INCIDENT": "Incidents"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _copilot_line(c: dict) -> str:
|
|
43
|
+
e = html.escape
|
|
44
|
+
return (f"<li>Copilot in {e(str(c['org']))}, last {c['window_days']} days (GitHub's own reports, {c['days_kept']} days kept "
|
|
45
|
+
f"since {e(c['first_day'])}): {c['monthly_active_users'] or 0} monthly active users, {_pct(c['acceptance'])} of "
|
|
46
|
+
f"suggestions accepted, {c['org_prs_by_copilot']} pull requests by Copilot across the org "
|
|
47
|
+
f"({c['org_prs_merged_by_copilot']} merged), {c['repo_prs_by_copilot']} in this repo.</li>")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _points(x: dict) -> str:
|
|
51
|
+
return "-" if x["points_scope"] is None else f"{x['points_done']:g} of {x['points_scope']:g}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _incident_link(i: dict, repo: str) -> str:
|
|
55
|
+
e = html.escape
|
|
56
|
+
if str(i.get("url") or "").startswith("https://"):
|
|
57
|
+
return f"<a href='{e(i['url'])}'>{e(str(i['number']))}</a>"
|
|
58
|
+
kind = "issues" if i["source"] == "issue" else "pull"
|
|
59
|
+
return f"<a href='https://github.com/{e(repo)}/{kind}/{int(i['number'])}'>#{int(i['number'])}</a>"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def render_html(r: dict, repo: str, pages=()) -> str:
|
|
63
|
+
e = html.escape
|
|
64
|
+
d = r["dora"]
|
|
65
|
+
sub = (f"Last {r['window_days']} days, updated {e(r['generated'])}. Deployments from: {e(r['deploy_source'] or 'none found')}"
|
|
66
|
+
f"{' (' + SURE[r.get('deploy_confidence')] + ')' if r.get('deploy_confidence') else ''}. "
|
|
67
|
+
f"DORA tiers: DevLake's {e(d['report'])} tables, counted as its DORA dashboard counts them.")
|
|
68
|
+
if r.get("deployments_unreadable"):
|
|
69
|
+
sub += " GitHub deployments could not be read: add `deployments: read` to the workflow's permissions to count them."
|
|
70
|
+
if r.get("deploy_confidence") == "low":
|
|
71
|
+
counted = ", ".join(f"{e(str(x['name']))} ({x['n']})" for x in r.get("deploy_names") or [])
|
|
72
|
+
sub += (f" Guessed from workflow names: {counted}. If these do not put code in front of users, set source = \"releases\" "
|
|
73
|
+
"or name the right workflow in deploy_workflow under [delivery] in .github/stackvitals.toml.")
|
|
74
|
+
body = [f"<h2>Cycle time</h2><p class=mut>{r['prs']} merged pull requests by people (bots and coding agents left out); "
|
|
75
|
+
f"{r['outliers']['over_60_days']} over 60 days and {r['outliers']['over_p98']} above the 98th percentile left out "
|
|
76
|
+
"(LinearB's outlier rule)"
|
|
77
|
+
+ (f", and {r['excluded']} the team excluded" if r.get("excluded") else "")
|
|
78
|
+
+ ". P75 is the number LinearB's bands use.</p><div class=tbl><table class=find><tr>"
|
|
79
|
+
"<th>Phase</th><th>What it measures</th><th>P50</th><th>P75</th><th>PRs</th><th>LinearB band</th></tr>"]
|
|
80
|
+
for name, label in NAMES.items():
|
|
81
|
+
s = r["phases"][name]
|
|
82
|
+
rng = f" <span class=mut>({_h(s['low_h'])}-{_h(s['high_h'])})</span>" if s["low_h"] is not None else ""
|
|
83
|
+
if not s.get("enough", True):
|
|
84
|
+
body.append(f"<tr><td>{label}</td><td class=mut>{HELP[name]}</td><td colspan=2>not enough data (under 10 PRs)</td>"
|
|
85
|
+
f"<td>n={s['n']}</td><td>-</td></tr>")
|
|
86
|
+
continue
|
|
87
|
+
body.append(f"<tr><td>{label}</td><td class=mut>{HELP[name]}</td><td>{_h(s['p50_h'])}{rng}</td><td>{_h(s['p75_h'])}</td>"
|
|
88
|
+
f"<td>n={s['n']}</td><td>{e(s['linearb_band'] or '-')}</td></tr>")
|
|
89
|
+
body.append("</table></div>")
|
|
90
|
+
for key, title, note in (("by_team", "By team", "Teams from [identity.teams] in .github/stackvitals.toml, by their members' PRs."),
|
|
91
|
+
("by_owner", "By owning team", "Teams in CODEOWNERS, by the PRs touching their paths; single people are left out.")):
|
|
92
|
+
groups = r.get(key) or {}
|
|
93
|
+
if groups:
|
|
94
|
+
body.append(f"<h3>{title}</h3><p class=mut>{note}</p><div class=tbl><table class=find><tr><th>Team</th>"
|
|
95
|
+
"<th>Cycle time to merge P50</th><th>P75</th><th>Pickup P75</th><th>Review P75</th><th>PRs</th></tr>")
|
|
96
|
+
for team, g in groups.items():
|
|
97
|
+
c = g["cycle_to_merge"]
|
|
98
|
+
cells = (f"<td>{_h(c['p50_h'])}</td><td>{_h(c['p75_h'])}</td><td>{_h(g['pickup']['p75_h'])}</td>"
|
|
99
|
+
f"<td>{_h(g['review']['p75_h'])}</td>") if c.get("enough", True) else "<td colspan=4>not enough data</td>"
|
|
100
|
+
body.append(f"<tr><td>{e(team)}</td>{cells}<td>n={g['n']}</td></tr>")
|
|
101
|
+
body.append("</table></div>")
|
|
102
|
+
body.append("<h2>DORA</h2><div class=tbl><table><tr><th>Key</th><th>Value</th><th>Tier</th></tr>"
|
|
103
|
+
f"<tr><td>Deployment frequency</td><td>{d['deployment_days_per_week'] if d['deployment_days_per_week'] is not None else '-'} "
|
|
104
|
+
f"deployment days a week (median), {d['deployments']} deployments</td><td>{DORA_TEXT[d['frequency_tier']]}</td></tr>"
|
|
105
|
+
f"<tr><td>Lead time for changes</td><td>{_h(d['lead_time_hours'])} (median of {d['lead_time_n']} deployed PRs)</td>"
|
|
106
|
+
f"<td>{DORA_TEXT[d['lead_time_tier']]}</td></tr>"
|
|
107
|
+
f"<tr><td>Change failure rate</td><td>{_pct(d['change_failure_rate'])} ({d['failed_deployments']} of {d['deployments']} "
|
|
108
|
+
f"deployments had an incident)</td><td>{DORA_TEXT[d['cfr_tier']]}</td></tr>"
|
|
109
|
+
f"<tr><td>Failed deployment recovery</td><td>{_h(d['recovery_hours'])} (median of {d['recovery_n']})</td>"
|
|
110
|
+
f"<td>{DORA_TEXT[d['recovery_tier']]}</td></tr>"
|
|
111
|
+
f"<tr><td>Deployment rework rate</td><td>{_pct(d.get('rework_rate'))} ({d.get('rework_deployments', 0)} of "
|
|
112
|
+
f"{d.get('deployments_n', 0)} deployments shipped a revert or hotfix)</td><td>-</td></tr></table></div>"
|
|
113
|
+
f"<p class=mut>Incidents from {e(r.get('incident_source') or 'GitHub issues, reverts and hotfixes')}, each linked "
|
|
114
|
+
"to the last deployment before it. Change the labels, the deploy workflow pattern or the source in "
|
|
115
|
+
".github/stackvitals.toml; a connected PagerDuty or incident.io is used instead of labels, reverts and hotfixes.</p>")
|
|
116
|
+
peers = r.get("peers")
|
|
117
|
+
if peers:
|
|
118
|
+
body.append(f"<h2>Compared with similar public repos</h2><p class=mut>{e(peers['group'])} ({peers['repos']} repos in "
|
|
119
|
+
f"the daily crawl{', ' + e(peers['fit']) if peers.get('fit') else ''}; {PEERS_ARE}, so they may not resemble a private "
|
|
120
|
+
"team's work). Measured the crawl's way for both: people's PRs opened in the last 90 days, pickup "
|
|
121
|
+
"from opening.</p><div class=tbl><table><tr><th>Measure</th><th>This repo</th><th>Typical (range)</th>"
|
|
122
|
+
"<th>Middle half</th><th>Place</th><th>Inside LinearB's good band</th></tr>")
|
|
123
|
+
for m in peers["metrics"]:
|
|
124
|
+
rng = f" ({_h(m['low'])}-{_h(m['high'])})" if m.get("low") is not None else ""
|
|
125
|
+
good = f"{m['linearb']['good']:.0%} of them" if m.get("linearb") else "-"
|
|
126
|
+
body.append(f"<tr><td>{e(m['name'])}</td><td>{_h(m['you'])}</td><td>{_h(m['typical'])}{rng}</td>"
|
|
127
|
+
f"<td>{_h(m['p25'])}-{_h(m['p75'])}</td><td>{e(m['place'])} (n={m['repos']})</td><td>{good}</td></tr>")
|
|
128
|
+
body.append("</table></div>")
|
|
129
|
+
if peers.get("population") or peers.get("gaps"):
|
|
130
|
+
body.append(f"<p class=mut>Who is compared: {e(peers.get('population') or '-')}. Not covered: "
|
|
131
|
+
f"{e(peers.get('gaps') or '-')}.</p>")
|
|
132
|
+
agreed = r.get("agreements")
|
|
133
|
+
if agreed:
|
|
134
|
+
body.append(f"<h2>Working agreements</h2><p class=mut>The team's own rules (under [agreements] in .github/stackvitals.toml), "
|
|
135
|
+
f"over the last {agreed['window_days']} days: {agreed['merged']} merged pull requests, plus open ones already "
|
|
136
|
+
"past a time limit.</p><div class=tbl><table class=find><tr><th data-filter>Agreement</th><th>Broken by</th></tr>")
|
|
137
|
+
for rule, label in agreed["labels"].items():
|
|
138
|
+
prs = [x for x in agreed["exceptions"] if x["rule"] == rule]
|
|
139
|
+
links = ", ".join(f"<a href='https://github.com/{e(repo)}/pull/{int(x['pr'])}'>#{int(x['pr'])}</a>"
|
|
140
|
+
+ (" (open)" if x["open"] else "") for x in prs[:20]) or "none"
|
|
141
|
+
body.append(f"<tr><td>{e(label)}</td><td>{agreed['counts'][rule]}: {links}</td></tr>")
|
|
142
|
+
body.append("</table></div>")
|
|
143
|
+
body.append("<h2>Stuck pull requests</h2>")
|
|
144
|
+
any_stuck = False
|
|
145
|
+
for key, label in STUCK.items():
|
|
146
|
+
items = r["stuck"].get(key) or []
|
|
147
|
+
if items:
|
|
148
|
+
any_stuck = True
|
|
149
|
+
body.append(f"<h3>{e(label)}: {len(items)}</h3><ul>" + "".join(
|
|
150
|
+
f"<li><a href='https://github.com/{e(repo)}/pull/{int(x['pr'])}'>#{int(x['pr'])}</a>"
|
|
151
|
+
+ (f" for {_h(x['hours'])}" if x.get("hours") is not None else "") + "</li>" for x in items[:30]) + "</ul>")
|
|
152
|
+
if not any_stuck:
|
|
153
|
+
body.append("<p>None.</p>")
|
|
154
|
+
sz = r["sizes"]
|
|
155
|
+
body.append(f"<h2>Pull request size</h2><p>Lines added plus removed, merged PRs (n={sz['n']}): under 100: {sz['under_100']}; "
|
|
156
|
+
f"100-155: {sz['100_155']}; 156-228: {sz['156_228']}; over 228: {sz['over_228']} (LinearB's bands). "
|
|
157
|
+
f"Median {sz['p50'] if sz['p50'] is not None else '-'} lines.</p>"
|
|
158
|
+
f"<p>Merged without any review or comment from someone else: {r['merged_without_review']['n']} "
|
|
159
|
+
f"({_pct(r['merged_without_review']['share'])}). Reviewed in under 20 minutes: {len(r['merged_flags']['lightning'])}. "
|
|
160
|
+
f"Review depth: {r['review_depth'] if r['review_depth'] is not None else '-'} comments and reviews per merged PR. "
|
|
161
|
+
f"Merge frequency: {r['merge_frequency'] if r['merge_frequency'] is not None else '-'} merged PRs per author per week.</p>")
|
|
162
|
+
if r["incidents"]:
|
|
163
|
+
body.append("<h2>Incidents</h2><div class=tbl><table class=find><tr><th data-filter>Source</th><th>Item</th><th>Opened</th>"
|
|
164
|
+
"<th>After deployment</th><th>Restored in</th></tr>" + "".join(
|
|
165
|
+
f"<tr><td>{e(i['source'])}</td><td>{_incident_link(i, repo)}</td>"
|
|
166
|
+
f"<td>{e(i['created'][:10])}</td><td>{e(i['deployment'] or 'none before it')}</td>"
|
|
167
|
+
f"<td>{_h(i['hours_to_restore'])}</td></tr>" for i in r["incidents"]) + "</table></div>")
|
|
168
|
+
q = r.get("merge_queue")
|
|
169
|
+
if q:
|
|
170
|
+
removed = ", ".join(f"{e(str(k))} {v}" for k, v in q["removals"].items()) or "none"
|
|
171
|
+
body.append(f"<h2>Merge queue</h2><p>{q['prs']} pull requests went through the merge queue in the last {q['window_days']} "
|
|
172
|
+
f"days; {q['merged']} merged, {_h(q['p50_h'])} from entering the queue to merge at the median and "
|
|
173
|
+
f"{_h(q['p90_h'])} for 9 in 10. Taken out of the queue: {removed}. Merge-group runs: {q['runs']}, "
|
|
174
|
+
f"{q['failed_runs']} failed.</p>")
|
|
175
|
+
if q["removed_by_flakes"]:
|
|
176
|
+
body.append("<p class=mut>Taken out after a merge-group run that failed only on jobs known to be flaky:</p><ul>" + "".join(
|
|
177
|
+
f"<li><a href='https://github.com/{e(repo)}/pull/{int(x['pr'])}'>#{int(x['pr'])}</a> on {e(x['at'][:10])} "
|
|
178
|
+
f"({e(', '.join(x['jobs']))})</li>" for x in q["removed_by_flakes"][:20]) + "</ul>")
|
|
179
|
+
inv = r.get("investment")
|
|
180
|
+
if inv:
|
|
181
|
+
cats = list(inv["counts"])
|
|
182
|
+
body.append(f"<h2>Where the work went</h2><p>{_mix(inv)}.</p><p class=mut>Merged PRs by people and coding agents since "
|
|
183
|
+
f"{e(inv['since'])}, sorted by the repo's own label rules, then a security label, the linked issue's type, "
|
|
184
|
+
"common labels, a changelog fragment, the title's prefix, the files changed (only docs, tests or CI) and "
|
|
185
|
+
f"the title's first verb; {inv['bots']} update-bot PRs counted apart. Decided by "
|
|
186
|
+
+ ", ".join(f"{e(k)} {v}" for k, v in inv["decided_by"].items()) + ".</p>"
|
|
187
|
+
"<div class=tbl><table><tr><th>Month</th>" + "".join(f"<th>{e(c)}</th>" for c in cats) + "<th>All</th></tr>"
|
|
188
|
+
+ "".join(f"<tr><td>{e(m['month'])}</td>" + "".join(f"<td>{m['counts'][c]}</td>" for c in cats)
|
|
189
|
+
+ f"<td>{m['n']}</td></tr>" for m in inv["months"]) + "</table></div>")
|
|
190
|
+
t = r.get("tracker")
|
|
191
|
+
if t:
|
|
192
|
+
names = " and ".join(TRACKERS.get(x, x) for x in t["sources"])
|
|
193
|
+
body.append(f"<h2>Issues from {e(names)}</h2><p class=mut>{t['issues']} issues read; {t['linked_prs']} of "
|
|
194
|
+
f"{t['merged_prs']} merged pull requests name one in their title or branch. Lead time runs from created to "
|
|
195
|
+
"done, cycle time from the first move to an in-progress status; canceled issues are left out.</p>"
|
|
196
|
+
"<div class=tbl><table><tr><th>Type</th><th>Done</th><th>Lead time P50</th><th>P75</th>"
|
|
197
|
+
"<th>Cycle time P50</th><th>P75</th></tr>" + "".join(
|
|
198
|
+
f"<tr><td>{ISSUE_TYPES.get(k, e(k))}</td><td>{v['n']}</td><td>{_h(v['lead_p50_h'])}</td>"
|
|
199
|
+
f"<td>{_h(v['lead_p75_h'])}</td><td>{_h(v['cycle_p50_h'])}</td><td>{_h(v['cycle_p75_h'])}</td></tr>"
|
|
200
|
+
for k, v in t["lead_time"].items()) + "</table></div>")
|
|
201
|
+
if t["planning"]:
|
|
202
|
+
body.append("<h3>Sprints and cycles</h3><p class=mut>Done means finished by the sprint's end; carried over means "
|
|
203
|
+
"still open when it closed; added after start means moved in once it had begun.</p><div class=tbl>"
|
|
204
|
+
"<table><tr><th>Sprint</th><th>State</th><th>Scope</th><th>Done</th><th>Carried over</th>"
|
|
205
|
+
"<th>Added after start</th><th>Points done</th></tr>" + "".join(
|
|
206
|
+
f"<tr><td>{e(str(x['name']))}</td><td>{e(x['state'].lower())}</td><td>{x['scope']}</td>"
|
|
207
|
+
f"<td>{x['done']}</td><td>{x['carried_over']}</td><td>{x['added_after_start']}</td>"
|
|
208
|
+
f"<td>{_points(x)}</td></tr>"
|
|
209
|
+
for x in reversed(t["planning"])) + "</table></div>")
|
|
210
|
+
ai = r["ai"]
|
|
211
|
+
a, s = ai["agent_prs"], ai["assisted_prs"]
|
|
212
|
+
tools = ", ".join(f"{e(k)} {v}" for k, v in sorted(s["tools"].items())) or "none"
|
|
213
|
+
body.append("<h2>AI and coding agents</h2><p class=mut>Totals only, never a person (an association, not a cause). AI help is "
|
|
214
|
+
"read from co-author trailers in a PR's last five commits and from labels coding tools add.</p><ul>"
|
|
215
|
+
f"<li>Pull requests by coding agents: {a['n']}, merged {_pct(a['merged_share'])}.</li>"
|
|
216
|
+
f"<li>People's merged PRs with AI help: {s['n']} ({_pct(s['share'])}; {tools}); pickup P75 "
|
|
217
|
+
f"{_h(s['pickup_p75_h'])} against {_h(s['others_pickup_p75_h'])} for the rest; size P75 {s['size_p75'] or '-'} lines "
|
|
218
|
+
f"against {s['others_size_p75'] or '-'}.</li>"
|
|
219
|
+
f"<li>Merged PRs reviewed by an AI review bot: {ai['ai_reviewed_prs']['n']}.</li>"
|
|
220
|
+
+ (f"<li>In {e(ai['baseline']['group'])} ({ai['baseline']['repos']} repos in the daily crawl, last 6 months), the "
|
|
221
|
+
f"typical repo merges {_pct(ai['baseline']['agent_merged'])} of coding agents' PRs and "
|
|
222
|
+
f"{_pct(ai['baseline']['outsider_merged'])} of outside contributors' PRs.</li>" if ai.get("baseline") else "")
|
|
223
|
+
+ (_copilot_line(ai["copilot"]) if ai.get("copilot") else "") + "</ul>")
|
|
224
|
+
out = ai.get("outcomes") or {}
|
|
225
|
+
w, matched = out.get("window"), out.get("matched") or {}
|
|
226
|
+
if w and (w.get("assisted", {}).get("n") or w.get("agent", {}).get("n")):
|
|
227
|
+
said = [f"<li>{e(label)}: {e(text)}</li>" for label, text in _matched_lines(matched)]
|
|
228
|
+
peers = ((ai.get("baseline") or {}).get("matched") or {}).get("merged_by_others")
|
|
229
|
+
if peers:
|
|
230
|
+
pts = lambda x: f"{x * 100:+.0f}" # noqa: E731
|
|
231
|
+
said.append(f"<li>Similar public repos ({ai['baseline']['matched']['repos']} {e(ai['baseline']['group'])}): coding "
|
|
232
|
+
f"agents' PRs merged by someone else {pts(peers['gap'])} points (likely {pts(peers['low'])} to "
|
|
233
|
+
f"{pts(peers['high'])}) against people's PRs of the same repo, month and size</li>")
|
|
234
|
+
if said:
|
|
235
|
+
body.append("<h3>Like with like</h3><p class=mut>A person's AI-marked PRs against the same person's other PRs of "
|
|
236
|
+
"a similar size; coding agents' PRs against people's PRs of the same month and size. Each says the "
|
|
237
|
+
"gap it could detect when it finds none.</p><ul>" + "".join(said) + "</ul>")
|
|
238
|
+
rows = (("Merged PRs", "n", str), ("Merge rate (merged of merged and closed)", "merge_rate", _pct),
|
|
239
|
+
("Pickup P75", "pickup_p75_h", _h), ("Review P75", "review_p75_h", _h),
|
|
240
|
+
("Size P75 (lines)", "size_p75", lambda x: "-" if x is None else f"{x:g}"),
|
|
241
|
+
("Merged since the CI history began", "ci_n", lambda x: "-" if x is None else str(x)),
|
|
242
|
+
("A CI run on the PR failed", "ci_failed_share", _pct),
|
|
243
|
+
("A failed run passed on re-run (flaky)", "flaky_share", _pct), ("Reverted later", "reverted_share", _pct),
|
|
244
|
+
("Shipped in a deployment with an incident", "failed_deploy_share", _pct))
|
|
245
|
+
side = lambda k: w.get(k) or {} # noqa: E731
|
|
246
|
+
body.append("<h3>After merge: raw shares, not matched</h3><p class=mut>The last 6 months of merged PRs. CI shares "
|
|
247
|
+
"count PRs merged since the CI history began; failed deployments count PRs in deployments of up to 5 PRs, "
|
|
248
|
+
"where an incident can be pinned near a PR.</p>"
|
|
249
|
+
"<div class=tbl><table><tr><th>Measure</th><th>Coding agents</th><th>AI-marked</th><th>Others</th></tr>"
|
|
250
|
+
+ "".join(f"<tr><td>{label}</td><td>{fmt(side('agent').get(k))}</td><td>{fmt(side('assisted').get(k))}</td>"
|
|
251
|
+
f"<td>{fmt(side('others').get(k))}</td></tr>" for label, k, fmt in rows) + "</table></div>")
|
|
252
|
+
return shell.page("Delivery", repo, "delivery.html", sub, "".join(body), pages)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
OUTCOME_WORDS = {"reverted": "reverted", "ci_failed": "a CI run failed", "failed_deploy": "shipped in a failed deployment",
|
|
256
|
+
"merged": "merged", "merged_by_others": "merged by someone else"}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _matched_lines(matched: dict) -> list[tuple[str, str]]:
|
|
260
|
+
# (who, what) for each like-for-like comparison with enough matched PRs.
|
|
261
|
+
out = []
|
|
262
|
+
for side, who in (("assisted", "AI-marked PRs against the same authors' other PRs"),
|
|
263
|
+
("agent", "Coding agents' PRs against people's of the same month and size")):
|
|
264
|
+
for key, words in OUTCOME_WORDS.items():
|
|
265
|
+
c = (matched.get(side) or {}).get(key) or {}
|
|
266
|
+
if c.get("gap") is None:
|
|
267
|
+
continue
|
|
268
|
+
if c["verdict"] == "no measurable difference":
|
|
269
|
+
text = f"{words}: no measurable difference (could detect {round(c['detectable'] * 100)} points or more)"
|
|
270
|
+
else:
|
|
271
|
+
pts = lambda x: f"{x * 100:+.0f}" # noqa: E731
|
|
272
|
+
text = (f"{words}: {'more' if c['verdict'] == 'higher' else 'less'} often, {pts(c['gap'])} points (likely "
|
|
273
|
+
f"{pts(c['low'])} to {pts(c['high'])}), {c['matched']} of {c['of']} PRs matched")
|
|
274
|
+
out.append((who, text))
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def render_markdown(r: dict, repo: str) -> str:
|
|
279
|
+
d, ph = r["dora"], r["phases"]
|
|
280
|
+
stuck = ", ".join(f"{STUCK[k].split(' (')[0].lower()} {len(v)}" for k, v in r["stuck"].items() if v) or "none"
|
|
281
|
+
return "\n".join([
|
|
282
|
+
f"### StackVitals Delivery: {repo.replace('@', '')}",
|
|
283
|
+
f"{r['prs']} merged PRs in {r['window_days']} days. Cycle time to merge P75 {_h(ph['cycle_to_merge']['p75_h'])}; "
|
|
284
|
+
f"pickup P75 {_h(ph['pickup']['p75_h'])}; review P75 {_h(ph['review']['p75_h'])}.",
|
|
285
|
+
f"DORA ({d['report']}, deployments from {r['deploy_source'] or 'none found'}): {d['deployment_days_per_week'] if d['deployment_days_per_week'] is not None else '-'} "
|
|
286
|
+
f"deployment days a week ({DORA_TEXT[d['frequency_tier']]}); lead time {_h(d['lead_time_hours'])} ({DORA_TEXT[d['lead_time_tier']]}); "
|
|
287
|
+
f"change failure rate {_pct(d['change_failure_rate'])} ({DORA_TEXT[d['cfr_tier']]}); recovery {_h(d['recovery_hours'])} "
|
|
288
|
+
f"({DORA_TEXT[d['recovery_tier']]}); rework rate {_pct(d.get('rework_rate'))}.",
|
|
289
|
+
f"Stuck PRs: {stuck}.",
|
|
290
|
+
*([f"Where the work went ({r['investment']['n']} merged PRs since {r['investment']['since']}): {_mix(r['investment'])}."]
|
|
291
|
+
if r.get("investment") else []), ""])
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _mix(inv: dict) -> str:
|
|
295
|
+
return ", ".join(f"{k} {_pct(v)}" for k, v in sorted(inv["shares"].items(), key=lambda kv: -kv[1]) if v)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
STUCK_LABEL = "stackvitals: waiting for review"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def label_stuck_prs(api, out: dict, open_prs: list[dict]) -> dict:
|
|
302
|
+
"""Opt-in: label the PRs whose asked-for review has not started after 3 days, and take the label off once one has."""
|
|
303
|
+
from urllib.parse import quote
|
|
304
|
+
hanging = {x["pr"] for x in out["stuck"]["hanging_review"]}
|
|
305
|
+
labelled = {p["n"] for p in open_prs if STUCK_LABEL in (p.get("lb") or [])}
|
|
306
|
+
for n in sorted(hanging - labelled):
|
|
307
|
+
api.send("POST", f"/repos/{api.repo}/issues/{n}/labels", {"labels": [STUCK_LABEL]})
|
|
308
|
+
for n in sorted(labelled - hanging):
|
|
309
|
+
api.send("DELETE", f"/repos/{api.repo}/issues/{n}/labels/{quote(STUCK_LABEL)}")
|
|
310
|
+
return {"added": len(hanging - labelled), "removed": len(labelled - hanging)}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def delivery_step(ws, env: dict, now, window_days: int = 90, label_stuck: bool = False, make_api=None) -> dict | None:
|
|
314
|
+
"""The Action's Delivery page, from Team Flow's PRs, CI Health's runs and Community's issues in the checkout. Runs when
|
|
315
|
+
Team Flow ran in the same job (the schedule)."""
|
|
316
|
+
from stackvitals.ci.store import DataStore
|
|
317
|
+
from stackvitals.core.config import parse
|
|
318
|
+
from stackvitals.core.delivery import build
|
|
319
|
+
from stackvitals.core.store import published, write_if_changed
|
|
320
|
+
from stackvitals.flow.store import FlowStore
|
|
321
|
+
if ws.results.get("Team Flow") is None:
|
|
322
|
+
return None
|
|
323
|
+
store = FlowStore(ws.path)
|
|
324
|
+
from stackvitals.core.decisions import visible
|
|
325
|
+
flow = visible(ws.path, ws.cache.get("flow") or store.load(), now.date())
|
|
326
|
+
ci = ws.cache.get("ci") or (DataStore(ws.path).load() if (ws.path / "ci" / "days").is_dir() else {"runs": [], "jobs": []})
|
|
327
|
+
issues = []
|
|
328
|
+
if (ws.path / "community" / "issues").is_dir():
|
|
329
|
+
found = FlowStore(ws.path, "community/issues").load()
|
|
330
|
+
issues = found["open"] + found["closed"]
|
|
331
|
+
cfg = ws.cache.get("settings") or parse("")[0]
|
|
332
|
+
info = store.repo()
|
|
333
|
+
from stackvitals.connectors import active, public
|
|
334
|
+
from stackvitals.connectors import load as load_tracker
|
|
335
|
+
# What the connectors with a key pulled (this job or earlier ones); a public repo's never carry titles.
|
|
336
|
+
tracker = ws.cache.get("tracker") or load_tracker(ws.path, redact=public(ws), names=active(env, cfg))
|
|
337
|
+
out = build(flow, info, ci, issues, now, window_days, cfg, tracker=tracker)
|
|
338
|
+
from stackvitals.core.agent_ledger import agent_of
|
|
339
|
+
from stackvitals.flow import survival
|
|
340
|
+
try:
|
|
341
|
+
lasted = json.loads((store.dir / survival.FILE).read_text(encoding="utf-8"))
|
|
342
|
+
except (OSError, ValueError):
|
|
343
|
+
lasted = {}
|
|
344
|
+
# Lines of people's and agents' PRs still in the default branch 30 and 90 days later.
|
|
345
|
+
out["code_survival"] = survival.summary(lasted, flow["closed"], lambda p: "agent" if agent_of(p) else "person")
|
|
346
|
+
from stackvitals.core.merge_queue import health as queue_health
|
|
347
|
+
try:
|
|
348
|
+
jobs = json.loads((ws.path / "docs" / "data.json").read_text(encoding="utf-8")).get("jobs") or []
|
|
349
|
+
except (OSError, ValueError):
|
|
350
|
+
jobs = []
|
|
351
|
+
# How long the merge queue holds pull requests, and which removals a known flaky job caused (CI Health's states).
|
|
352
|
+
out["merge_queue"] = queue_health(flow["open"] + flow["closed"], ci, {(j.get("workflow"), j.get("job")): j.get("state")
|
|
353
|
+
for j in jobs}, now, window_days)
|
|
354
|
+
from stackvitals.connectors import copilot_summary
|
|
355
|
+
out["ai"]["copilot"] = copilot_summary(ws.path, now) # the org's Copilot reports, kept day by day, when connected
|
|
356
|
+
from stackvitals.connectors.ai_spend import summary as spend_summary
|
|
357
|
+
from stackvitals.core.ai_outcomes import mode
|
|
358
|
+
# What the AI tools cost in the last 30 days, beside the agents' and AI-marked PRs merged in the same days.
|
|
359
|
+
month = iso(now - timedelta(days=30))
|
|
360
|
+
out["ai"]["spend"] = spend_summary(ws.path, now, sum(1 for p in flow["closed"] if (p.get("mg") or "") >= month
|
|
361
|
+
and mode(p) in ("agent", "assisted")))
|
|
362
|
+
from stackvitals.connectors.sonar import summary as sonar_summary
|
|
363
|
+
# The Sonar quality gate and measures over time, when [connectors.sonar] names a project.
|
|
364
|
+
out["code_quality"] = sonar_summary(ws.path, now)
|
|
365
|
+
out["excluded"] = flow.get("excluded", 0)
|
|
366
|
+
from stackvitals.core import delivery_peers
|
|
367
|
+
# Where it stands among similar public repos (the crawl's file; absent before launch or offline: no section).
|
|
368
|
+
out["peers"] = delivery_peers.compare(delivery_peers.own_metrics(flow["open"] + flow["closed"], now),
|
|
369
|
+
delivery_peers.load(None, ws.work / "bench"), # public source only, like Team Flow
|
|
370
|
+
info.get("language"), info.get("stars"))
|
|
371
|
+
from stackvitals.core.ai_outcomes import baseline
|
|
372
|
+
from stackvitals.flow import peers as flow_peers
|
|
373
|
+
# Coding agents' merge share in similar public repos (Team Flow loaded the crawl's file earlier in this job).
|
|
374
|
+
from stackvitals.core import agent_ledger
|
|
375
|
+
flow_bench = flow_peers.load(None, ws.work / "bench")
|
|
376
|
+
out["ai"]["baseline"] = baseline(flow_bench, info.get("language"), info.get("stars"))
|
|
377
|
+
# Each coding agent across the crawl's public repos, for this repo's own agents to be read against.
|
|
378
|
+
out["ai"]["leaderboard"] = agent_ledger.leaderboard((flow_bench or {}).get("agent_leaderboard"))
|
|
379
|
+
from stackvitals.core import capex
|
|
380
|
+
from stackvitals.core.investment import tracker_types
|
|
381
|
+
# For finance: every merged PR of the last year with its kind of work and epic (docs/capex.csv), and a summary.
|
|
382
|
+
epics = {}
|
|
383
|
+
if tracker:
|
|
384
|
+
from stackvitals.connectors.tracker import pr_links
|
|
385
|
+
by_key = {i.get("key"): i for i in tracker["issues"]}
|
|
386
|
+
epics = {n: (by_key.get(k) or {}).get("epic") or "" for n, k in pr_links(flow["closed"], tracker["issues"])}
|
|
387
|
+
cap_rows = capex.rows(flow["closed"], ws.repo, now, private=not public(ws), base=info.get("default_branch"),
|
|
388
|
+
rules=(cfg.get("investment") or {}).get("labels"),
|
|
389
|
+
tracker_types=tracker_types(flow["closed"], tracker["issues"]) if tracker else None, epics=epics)
|
|
390
|
+
out["capex"] = {"months": capex.summary(cap_rows)[-6:], "rows": len(cap_rows), "file": "capex.csv"}
|
|
391
|
+
(ws.path / "docs").mkdir(parents=True, exist_ok=True)
|
|
392
|
+
(ws.path / "docs" / "capex.csv").write_text(capex.to_csv(cap_rows), encoding="utf-8", newline="\n")
|
|
393
|
+
ws.cache["delivery"] = out # the Insights step after this one reads it
|
|
394
|
+
# Working-agreement nudges: each open pull request breaking one is announced once, decided before the commit.
|
|
395
|
+
from stackvitals.core.agreements import nudge_lines, nudges
|
|
396
|
+
fresh, keep = nudges(out.get("agreements"), store.state().get("nudged") or [])
|
|
397
|
+
if keep != (store.state().get("nudged") or []):
|
|
398
|
+
store.save_state({**store.state(), "nudged": keep})
|
|
399
|
+
from stackvitals.core.export import devlake_tables
|
|
400
|
+
from stackvitals.core.summary import write_devlake_summary
|
|
401
|
+
# CI runs of the last 90 days go into the data branch's DevLake tables, so the summary stays small on busy repos.
|
|
402
|
+
write_devlake_summary(ws.path, devlake_tables(ws.repo, flow, store.repo(), ci, issues, cfg, tracker=tracker,
|
|
403
|
+
ci_since=iso(now - timedelta(days=90))), now)
|
|
404
|
+
docs = ws.path / "docs"
|
|
405
|
+
docs.mkdir(parents=True, exist_ok=True)
|
|
406
|
+
write_if_changed(docs / "delivery.json", published(out),
|
|
407
|
+
also=lambda: (docs / "delivery.html").write_text(
|
|
408
|
+
render_html(out, ws.repo, shell.present(docs) | {"delivery.html"}), encoding="utf-8"))
|
|
409
|
+
|
|
410
|
+
def after_commit():
|
|
411
|
+
summary = env.get("GITHUB_STEP_SUMMARY")
|
|
412
|
+
failed = None
|
|
413
|
+
if fresh and env.get("STACKVITALS_WEBHOOK"):
|
|
414
|
+
from stackvitals.core import chat
|
|
415
|
+
lines = nudge_lines(fresh, out["agreements"]["labels"], ws.repo)
|
|
416
|
+
failed = chat.post(env["STACKVITALS_WEBHOOK"], chat.message(
|
|
417
|
+
f"Working agreements in {ws.repo}: {len(fresh)} pull request{'' if len(fresh) == 1 else 's'} to look at", lines,
|
|
418
|
+
[], {"repo": ws.repo, "nudges": [{"pr": e["pr"], "rule": e["rule"]} for e in fresh]}), what="the agreements webhook")
|
|
419
|
+
if summary:
|
|
420
|
+
with open(summary, "a", encoding="utf-8") as f:
|
|
421
|
+
f.write(render_markdown(out, ws.repo) + (f"\n{failed}" if failed else "") + "\n")
|
|
422
|
+
ws.after_commit(after_commit, "Delivery")
|
|
423
|
+
result = {"prs": out["prs"], "deployments": out["dora"]["deployments"], "generated": iso(now)}
|
|
424
|
+
if label_stuck:
|
|
425
|
+
if make_api is None:
|
|
426
|
+
from stackvitals.ci.api import ActionsAPI as make_api
|
|
427
|
+
result["labelled"] = label_stuck_prs(make_api(env.get("STACKVITALS_TOKEN"), ws.repo, max_requests=60), out,
|
|
428
|
+
flow.get("open") or [])
|
|
429
|
+
return result
|