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,333 @@
|
|
|
1
|
+
"""The weekly digest: what changed since last week in each module that ran, what to fix first, and which decisions reach
|
|
2
|
+
their review date soon, as one short message posted where people already look (a comment on one pinned issue, so
|
|
3
|
+
GitHub's own notifications deliver it, and the chat webhook). Nobody is pinged and item names can't break the Markdown."""
|
|
4
|
+
import html
|
|
5
|
+
import os
|
|
6
|
+
from datetime import date, datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from stackvitals.core import changes
|
|
9
|
+
|
|
10
|
+
TOP = 3
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _code(text) -> str:
|
|
14
|
+
# An item name as inline code that nothing inside it can break out of.
|
|
15
|
+
return "`" + " ".join(str(text).replace("`", "'").split()) + "`"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _quiet(text) -> str:
|
|
19
|
+
# Free text (titles, reasons) that can't ping anyone, become a link or start Markdown.
|
|
20
|
+
out = html.escape(" ".join(str(text).split()), quote=False)
|
|
21
|
+
for ch in "\\*_[]()#|":
|
|
22
|
+
out = out.replace(ch, "\\" + ch)
|
|
23
|
+
return out.replace("@", "@").replace("www.", "www.").replace("://", "://")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _pct(x) -> str:
|
|
27
|
+
return f"{x:.0%}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _moved(c: dict) -> str:
|
|
31
|
+
# One change that survived the scan, in words: "pickup rose to 30.0 h from 3.0 h (medians, n 6 and 20)".
|
|
32
|
+
verb = "rose" if c["direction"] == "up" else "fell"
|
|
33
|
+
if c["unit"] == "share":
|
|
34
|
+
return f"{c['label']} {verb} to {_pct(c['now'])} from {_pct(c['base'])} (n {c['n_now']} and {c['n_base']})"
|
|
35
|
+
if c["unit"] == "count":
|
|
36
|
+
return f"{c['label']} {verb} to {c['now']} from {c['base']:g} a week"
|
|
37
|
+
return f"{c['label']} {verb} to {c['now']:.1f} {c['unit']} from {c['base']:.1f} {c['unit']} (medians, n {c['n_now']} and {c['n_base']})"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _could_see(checked: list[dict]) -> str:
|
|
41
|
+
# When review times held steady, how big a change this week's PRs could have shown (so "steady" isn't oversold).
|
|
42
|
+
run = sum(1 for c in checked if c.get("note") is None)
|
|
43
|
+
times = [(changes.detectable(c, run), c) for c in checked if c["metric"] in ("delivery.pickup", "delivery.cycle_to_merge")
|
|
44
|
+
and c.get("note") is None]
|
|
45
|
+
times = [(f, c) for f, c in times if f]
|
|
46
|
+
if not times or any(c["changed"] for c in checked if c["metric"].startswith(("delivery.pickup", "delivery.cycle"))):
|
|
47
|
+
return ""
|
|
48
|
+
f, c = min(times, key=lambda x: x[0])
|
|
49
|
+
size = f"{f:.0f}" if f >= 3 else f"{f:.1f}"
|
|
50
|
+
return (f"; with {c['n_now']} PRs merged this week, review times would have had to change about {size}× to "
|
|
51
|
+
"stand out")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _verdict(name: str, c: dict) -> str | None:
|
|
55
|
+
# One like-with-like comparison in words: "reverted more often (+8 points, likely +2 to +14)".
|
|
56
|
+
if not c or c.get("verdict") in (None, "too few"):
|
|
57
|
+
return None
|
|
58
|
+
if c["verdict"] == "no measurable difference":
|
|
59
|
+
return f"{name}: no measurable difference (it could detect {round(c['detectable'] * 100)} points or more)"
|
|
60
|
+
pts = lambda x: f"{x * 100:+.0f}" # noqa: E731
|
|
61
|
+
return f"{name} {'more' if c['verdict'] == 'higher' else 'less'} often ({pts(c['gap'])} points, likely {pts(c['low'])} to {pts(c['high'])})"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ai_line(matched: dict, coverage: dict) -> str | None:
|
|
65
|
+
"""People's AI-marked PRs against the same authors' other PRs, and agents' PRs against people's of the same month and
|
|
66
|
+
size; only comparisons with enough matched PRs, each with its verdict."""
|
|
67
|
+
a = matched.get("assisted") or {}
|
|
68
|
+
said = [v for v in (_verdict("reverted", a.get("reverted")), _verdict("CI failures", a.get("ci_failed"))) if v]
|
|
69
|
+
agent = _verdict("merged by someone else", (matched.get("agent") or {}).get("merged_by_others"))
|
|
70
|
+
if not said and not agent:
|
|
71
|
+
return None
|
|
72
|
+
text = "**AI-assisted work:**"
|
|
73
|
+
if coverage.get("marked_share") is not None:
|
|
74
|
+
text += (f" {_pct(coverage['marked_share'])} of people's merged PRs carry an AI marker "
|
|
75
|
+
f"({coverage.get('authors_marking', 0)} of {coverage.get('authors', 0)} authors).")
|
|
76
|
+
if said:
|
|
77
|
+
text += f" Against the same authors' other PRs: {'; '.join(said)}."
|
|
78
|
+
if agent and "no measurable difference" in agent:
|
|
79
|
+
text += f" Coding agents' PRs against people's PRs of the same month and size, {agent}."
|
|
80
|
+
elif agent:
|
|
81
|
+
text += f" Coding agents' PRs: {agent} than people's PRs of the same month and size."
|
|
82
|
+
return text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _flaky_keys(data: dict) -> list[str]:
|
|
86
|
+
keys = [f"test {t['id']}" for t in data.get("tests") or [] if t.get("state") == "flaky"]
|
|
87
|
+
keys += [f"job {(j.get('workflow') or '?').rsplit('/', 1)[-1]} / {j.get('job')}" for j in data.get("jobs") or []
|
|
88
|
+
if j.get("state") == "flaky"]
|
|
89
|
+
return sorted(keys)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def numbers(docs: dict) -> dict:
|
|
93
|
+
"""The figures next week's digest compares with."""
|
|
94
|
+
data, delivery, flow = docs.get("data") or {}, docs.get("delivery") or {}, docs.get("flow") or {}
|
|
95
|
+
phases = delivery.get("phases") or {}
|
|
96
|
+
p75 = lambda name: (phases.get(name) or {}).get("p75_h")
|
|
97
|
+
return {"first_try": (data.get("pushes") or {}).get("first_try_share") if data else None,
|
|
98
|
+
"flaky": _flaky_keys(data) if data else [],
|
|
99
|
+
"pickup_p75_h": p75("pickup"), "review_p75_h": p75("review"), "cycle_p75_h": p75("cycle_to_merge"),
|
|
100
|
+
"deployments": (delivery.get("dora") or {}).get("deployments"), "open_prs": flow.get("open"),
|
|
101
|
+
"newcomers": (docs.get("community") or {}).get("waiting_total")}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def due(state: dict, today: date, days: int = 7) -> list[dict]:
|
|
105
|
+
"""Decisions whose review date falls in the next `days` days, soonest first."""
|
|
106
|
+
end = (today + timedelta(days=days)).isoformat()
|
|
107
|
+
return sorted((v for v in state.values() if v.get("until") and today.isoformat() <= v["until"] <= end),
|
|
108
|
+
key=lambda v: v["until"])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def compose(repo: str, now: datetime, docs: dict, last: dict | None, decisions_due: list[dict],
|
|
112
|
+
app_url: str | None) -> tuple[str, dict]:
|
|
113
|
+
"""(Markdown, numbers): this week's digest from the published JSON files, and what to compare against next week."""
|
|
114
|
+
nums, before = numbers(docs), (last or {}).get("numbers") or {}
|
|
115
|
+
week = (now.date() - timedelta(days=now.weekday())).isoformat()
|
|
116
|
+
lines = [f"### StackVitals weekly digest: {_quiet(repo)}, week of {week}", ""]
|
|
117
|
+
findings = (docs.get("insights") or {}).get("findings") or []
|
|
118
|
+
# Findings someone snoozed or acknowledged stay off the list, as on the pages.
|
|
119
|
+
aside = sum(1 for f in findings if f.get("decision"))
|
|
120
|
+
found = sorted((f for f in findings if not f.get("decision")), key=lambda f: -(f.get("hours") or 0))[:TOP]
|
|
121
|
+
if found:
|
|
122
|
+
lines.append("**What to fix first**")
|
|
123
|
+
lines += [f"{i}. {_quiet(f.get('title'))} ({f.get('hours') or 0:.1f} h at stake, n={f.get('n')})"
|
|
124
|
+
for i, f in enumerate(found, 1)]
|
|
125
|
+
if aside:
|
|
126
|
+
lines.append(f"({aside} set aside by a decision.)")
|
|
127
|
+
lines.append("")
|
|
128
|
+
checked = changes.flag([c for name in ("data", "delivery", "community") for c in (docs.get(name) or {}).get("changes") or []])
|
|
129
|
+
run = [c for c in checked if c.get("note") is None and not c.get("shown_with")]
|
|
130
|
+
if checked and not run:
|
|
131
|
+
lines.append("**This week:** too few PRs, pushes or deployments to compare with the four weeks before.")
|
|
132
|
+
elif run:
|
|
133
|
+
moved = [_moved(c) for c in run if c["changed"]]
|
|
134
|
+
held = len(run) - len(moved)
|
|
135
|
+
tail = (f"{len(run)} number{'' if len(run) == 1 else 's'} checked against the four weeks before; "
|
|
136
|
+
+ (f"the other {held} held steady within noise" if moved else "all held steady within noise"))
|
|
137
|
+
tail += _could_see(run)
|
|
138
|
+
lines.append(f"**This week:** {'; '.join(moved)}. {tail}." if moved else f"**This week:** {tail}.")
|
|
139
|
+
if docs.get("data"):
|
|
140
|
+
part = f"first-try green {_pct(nums['first_try'])}" if nums["first_try"] is not None else "first-try green: not enough runs"
|
|
141
|
+
if last:
|
|
142
|
+
new = [k for k in nums["flaky"] if k not in set(before.get("flaky") or [])]
|
|
143
|
+
gone = [k for k in before.get("flaky") or [] if k not in set(nums["flaky"])]
|
|
144
|
+
if new:
|
|
145
|
+
part += "; newly flaky: " + ", ".join(_code(k.split(" ", 1)[1]) for k in new[:5]) + (
|
|
146
|
+
f" and {len(new) - 5} more" if len(new) > 5 else "")
|
|
147
|
+
if gone:
|
|
148
|
+
part += f"; {len(gone)} recovered"
|
|
149
|
+
else:
|
|
150
|
+
part += f"; {len(nums['flaky'])} flaky now"
|
|
151
|
+
lines.append(f"**CI:** {part}.")
|
|
152
|
+
if docs.get("delivery"):
|
|
153
|
+
parts = []
|
|
154
|
+
for key, label in (("pickup_p75_h", "pickup P75"), ("review_p75_h", "review P75"), ("cycle_p75_h", "cycle to merge P75")):
|
|
155
|
+
value = nums[key]
|
|
156
|
+
parts.append(f"{label} not enough data" if value is None else f"{label} {value:.1f} h")
|
|
157
|
+
dora = docs["delivery"].get("dora") or {}
|
|
158
|
+
shipped = dora.get("deployments") or 0
|
|
159
|
+
tail = (f"; {shipped} deployment{'' if shipped == 1 else 's'} in {docs['delivery'].get('window_days', 90)} days"
|
|
160
|
+
if docs["delivery"].get("deploy_source") else "; no deployment source found yet")
|
|
161
|
+
if dora.get("change_failure_rate") is not None:
|
|
162
|
+
tail += f"; change failure {_pct(dora['change_failure_rate'])}"
|
|
163
|
+
if dora.get("rework_rate") is not None:
|
|
164
|
+
tail += f"; rework {_pct(dora['rework_rate'])}"
|
|
165
|
+
lines.append(f"**Delivery:** {', '.join(parts)}{tail}.")
|
|
166
|
+
ai = (docs["delivery"].get("ai") or {}).get("outcomes") or {}
|
|
167
|
+
line = _ai_line(ai.get("matched") or {}, ai.get("coverage") or {})
|
|
168
|
+
if line:
|
|
169
|
+
lines.append(line)
|
|
170
|
+
agreed = docs["delivery"].get("agreements")
|
|
171
|
+
if agreed:
|
|
172
|
+
n = len(agreed.get("exceptions") or [])
|
|
173
|
+
each = "; ".join(f"{_quiet(label)}: {agreed['counts'].get(rule, 0)}" for rule, label in agreed["labels"].items())
|
|
174
|
+
lines.append(f"**Working agreements:** {n} exception{'' if n == 1 else 's'} in the last "
|
|
175
|
+
f"{agreed.get('window_days', 14)} days ({each}).")
|
|
176
|
+
if docs.get("flow"):
|
|
177
|
+
n_open, stale = docs["flow"].get("open") or 0, docs["flow"].get("stale") or 0
|
|
178
|
+
lines.append(f"**Pull requests:** {n_open} open pull request{'' if n_open == 1 else 's'}, {stale} stale.")
|
|
179
|
+
deps = docs.get("deps")
|
|
180
|
+
if deps:
|
|
181
|
+
parts = []
|
|
182
|
+
if deps.get("open"):
|
|
183
|
+
n, oldest = len(deps["open"]), max(u["age_hours"] for u in deps["open"]) / 24
|
|
184
|
+
parts.append(f"{n} update pull request{'' if n == 1 else 's'} open (the oldest {oldest:.0f} days)")
|
|
185
|
+
if deps.get("security_overdue"):
|
|
186
|
+
n = len(deps["security_overdue"])
|
|
187
|
+
parts.append(f"{n} security update{'' if n == 1 else 's'} waiting over 48 h")
|
|
188
|
+
if (deps.get("quiet_days") or 0) >= 30:
|
|
189
|
+
parts.append(f"no update pull request from {' or '.join(deps.get('bots') or ['the bot'])} for {deps['quiet_days']} "
|
|
190
|
+
"days (Dependabot pauses after 90 days without interaction)")
|
|
191
|
+
if parts:
|
|
192
|
+
lines.append(f"**Dependencies:** {'; '.join(parts)}.")
|
|
193
|
+
if docs.get("community"):
|
|
194
|
+
waiting = docs["community"].get("waiting_total") or 0
|
|
195
|
+
lines.append(f"**Community:** {waiting} newcomer{'' if waiting == 1 else 's'} waiting for a first answer."
|
|
196
|
+
if waiting else "**Community:** no newcomer waiting for a first answer.")
|
|
197
|
+
if decisions_due:
|
|
198
|
+
lines += ["", "**Decisions to review**"]
|
|
199
|
+
lines += [f"- {d['verb']} of {_code(d['id'])} ends {d['until']} (by {_code(d.get('by') or '?')}"
|
|
200
|
+
+ (f": {_quiet(d['reason'])}" if d.get("reason") else "") + ")" for d in decisions_due]
|
|
201
|
+
if app_url:
|
|
202
|
+
lines += ["", f"[Open StackVitals]({app_url})"]
|
|
203
|
+
return "\n".join(lines).strip() + "\n", nums
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
TITLE = "StackVitals weekly digest"
|
|
207
|
+
LABEL = "stackvitals"
|
|
208
|
+
INTRO = ("StackVitals posts a short digest here every week: what changed in CI, delivery and pull requests, what to fix "
|
|
209
|
+
"first, and which decisions end soon. Subscribe to this issue to get it by e-mail or in GitHub. Change the day, "
|
|
210
|
+
"hour or channels under `[digest]` in `.github/stackvitals.toml`, or turn it off with `enabled = false`.")
|
|
211
|
+
FILES = (("data", "data.json"), ("flow", "flow.json"), ("delivery", "delivery.json"), ("insights", "insights.json"),
|
|
212
|
+
("community", "community.json"), ("deps", "deps.json"))
|
|
213
|
+
EVENTS = ("schedule", "workflow_dispatch")
|
|
214
|
+
CATCH_UP = timedelta(hours=12)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def slot(now: datetime, day: int, hour: int) -> tuple[str, datetime] | None:
|
|
218
|
+
"""(ISO week, time) of the digest due now: this week's slot once reached, or last week's when missed by under 12
|
|
219
|
+
hours (a Sunday-night slot falls after the week's last run); None when nothing is due."""
|
|
220
|
+
start = datetime(now.year, now.month, now.day, tzinfo=now.tzinfo) - timedelta(days=now.weekday())
|
|
221
|
+
at = start + timedelta(days=day, hours=hour)
|
|
222
|
+
if now < at:
|
|
223
|
+
at -= timedelta(days=7)
|
|
224
|
+
if now - at > CATCH_UP:
|
|
225
|
+
return None
|
|
226
|
+
year, week, _ = at.isocalendar()
|
|
227
|
+
return f"{year}-W{week:02d}", at
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _read(path):
|
|
231
|
+
import json
|
|
232
|
+
try:
|
|
233
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
234
|
+
except (OSError, ValueError):
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _issue(api, repo: str) -> int:
|
|
239
|
+
"""The digest issue: the open one with its title and label, else a new one, pinned when GitHub allows it."""
|
|
240
|
+
found = [i for i in api._get(f"/repos/{repo}/issues", {"labels": LABEL, "state": "open", "per_page": 100}) or []
|
|
241
|
+
if i.get("title") == TITLE and "pull_request" not in i]
|
|
242
|
+
if found:
|
|
243
|
+
return found[0]["number"]
|
|
244
|
+
made = api.send("POST", f"/repos/{repo}/issues", {"title": TITLE, "body": INTRO, "labels": [LABEL]})
|
|
245
|
+
try:
|
|
246
|
+
from stackvitals.ci.api import GRAPHQL
|
|
247
|
+
api.client.post(GRAPHQL, headers=api.headers, json={
|
|
248
|
+
"query": "mutation($id: ID!) { pinIssue(input: {issueId: $id}) { issue { number } } }",
|
|
249
|
+
"variables": {"id": made.get("node_id")}})
|
|
250
|
+
except Exception: # noqa: BLE001 - an unpinned digest still works
|
|
251
|
+
pass
|
|
252
|
+
return made["number"]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def digest_step(ws, env: dict, now: datetime, make_api=None, client=None) -> dict | None:
|
|
256
|
+
"""Once per ISO week, on the first scheduled run at or after the repo's day and hour: the digest from the pages this
|
|
257
|
+
job just wrote, saved as sent before the commit and posted after it."""
|
|
258
|
+
import json
|
|
259
|
+
|
|
260
|
+
from stackvitals.core import chat
|
|
261
|
+
from stackvitals.core.config import DAYS, parse
|
|
262
|
+
from stackvitals.core.decisions import Store
|
|
263
|
+
from stackvitals.core.time import iso
|
|
264
|
+
cfg = (ws.cache.get("settings") or parse("")[0])["digest"]
|
|
265
|
+
if not cfg["enabled"] or env.get("GITHUB_EVENT_NAME") not in EVENTS:
|
|
266
|
+
return None
|
|
267
|
+
due_slot = slot(now, DAYS.index(cfg["day"]), cfg["hour"])
|
|
268
|
+
path = ws.path / "digest" / "last.json"
|
|
269
|
+
last = _read(path)
|
|
270
|
+
if due_slot is None or (last or {}).get("week") == due_slot[0]:
|
|
271
|
+
return None
|
|
272
|
+
week_id = due_slot[0]
|
|
273
|
+
docs = {name: _read(ws.path / "docs" / file) for name, file in FILES}
|
|
274
|
+
if not any(docs.values()):
|
|
275
|
+
return None
|
|
276
|
+
if make_api is None:
|
|
277
|
+
from stackvitals.ci.api import ActionsAPI as make_api
|
|
278
|
+
api = make_api(env.get("STACKVITALS_TOKEN") or env.get("GITHUB_TOKEN"), ws.repo, max_requests=20)
|
|
279
|
+
try:
|
|
280
|
+
info = api._get(f"/repos/{ws.repo}")
|
|
281
|
+
except Exception: # noqa: BLE001 - without the link the digest still says everything
|
|
282
|
+
info = {}
|
|
283
|
+
owner, name = ws.repo.split("/")
|
|
284
|
+
app_url = f"https://{owner.lower()}.github.io/{name}/" if info.get("has_pages") and not info.get("private") else None
|
|
285
|
+
text, numbers = compose(ws.repo, due_slot[1], docs, last, due(Store(ws.path).state(now.date()), now.date()), app_url)
|
|
286
|
+
# Last week's pulse, counted from reactions in the digest issue (read before the commit; nothing about who answered).
|
|
287
|
+
pulse = cfg.get("pulse") and "issue" in cfg["channels"]
|
|
288
|
+
if pulse and last and last.get("week"):
|
|
289
|
+
from stackvitals.core.pulse import record, section, tally
|
|
290
|
+
try:
|
|
291
|
+
keep = None
|
|
292
|
+
if not info.get("private"):
|
|
293
|
+
# Anyone can react on a public repo: count only the team's own people.
|
|
294
|
+
from stackvitals.core.actors import People
|
|
295
|
+
from stackvitals.flow.store import FlowStore
|
|
296
|
+
flow = ws.cache.get("flow") or FlowStore(ws.path).load()
|
|
297
|
+
people = People((flow.get("closed") or []) + (flow.get("open") or []))
|
|
298
|
+
keep = lambda login: people.role(login) == "insider" # noqa: E731
|
|
299
|
+
counts = tally(api, _issue(api, ws.repo), last["week"], since=last.get("sent"), keep=keep)
|
|
300
|
+
record(ws.path, last["week"], counts) # the week's counts for the app's trend; never who answered
|
|
301
|
+
line = section(counts)
|
|
302
|
+
except Exception: # noqa: BLE001 - the digest goes out without it
|
|
303
|
+
line = None
|
|
304
|
+
if line:
|
|
305
|
+
text = f"{text}\n\n{line}"
|
|
306
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
path.write_text(json.dumps({"week": week_id, "sent": iso(now), "numbers": numbers}, indent=1), encoding="utf-8")
|
|
308
|
+
notes = []
|
|
309
|
+
|
|
310
|
+
def send():
|
|
311
|
+
if "issue" in cfg["channels"]:
|
|
312
|
+
try:
|
|
313
|
+
api.send("POST", f"/repos/{ws.repo}/issues/{_issue(api, ws.repo)}/comments", {"body": text})
|
|
314
|
+
if pulse:
|
|
315
|
+
from stackvitals.core.pulse import ask
|
|
316
|
+
ask(api, _issue(api, ws.repo), week_id)
|
|
317
|
+
except Exception as exc: # noqa: BLE001 - reported, never fails the job
|
|
318
|
+
notes.append(f"Could not post the weekly digest ({type(exc).__name__}); it needs `issues: write`.")
|
|
319
|
+
if "webhook" in cfg["channels"] and env.get("STACKVITALS_WEBHOOK"):
|
|
320
|
+
lines = [x for x in text.splitlines() if x.strip()]
|
|
321
|
+
body = [x for x in lines[1:] if not x.startswith("[Open StackVitals]")]
|
|
322
|
+
msg = chat.message(lines[0].lstrip("# ").strip(), body, [("Open StackVitals", app_url)] if app_url else [],
|
|
323
|
+
{"repo": ws.repo, "week": week_id})
|
|
324
|
+
failed = chat.post(env["STACKVITALS_WEBHOOK"], msg, client, "the digest webhook")
|
|
325
|
+
if failed:
|
|
326
|
+
notes.append(failed)
|
|
327
|
+
summary = env.get("GITHUB_STEP_SUMMARY")
|
|
328
|
+
if summary and notes:
|
|
329
|
+
with open(summary, "a", encoding="utf-8") as f:
|
|
330
|
+
f.write("### StackVitals digest\n" + "\n".join(notes) + "\n")
|
|
331
|
+
if not os.environ.get("STACKVITALS_READ_ONLY"): # a local read-only run sends nothing anywhere
|
|
332
|
+
ws.after_commit(send, "Digest")
|
|
333
|
+
return {"week": week_id, "channels": list(cfg["channels"])}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""`stackvitals explain owner/repo lead-time`: a headline number, what it means, and the pull requests, deployments or
|
|
2
|
+
pushes it was counted from, each with its GitHub link. Reads the repo's published files (a local copy of its
|
|
3
|
+
stackvitals-data branch, as `stackvitals ui` keeps, or a docs folder with --docs)."""
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
PHASES = ("coding", "pickup", "review", "approve", "merge", "deploy", "cycle", "cycle-to-merge")
|
|
10
|
+
NUMBERS = ("deployments", "lead-time", "change-failure", "recovery", "first-try", *PHASES)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _hours(minutes) -> str:
|
|
14
|
+
return "?" if minutes is None else f"{minutes / 60:.1f} h"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _pct(x) -> str:
|
|
18
|
+
return "?" if x is None else f"{round(x * 100)}%"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def explain(repo: str, what: str, delivery: dict | None, data: dict | None) -> dict | None:
|
|
22
|
+
"""{"headline", "meaning", "items": [{..., "url"}], "total"} for one number, or None when it isn't published."""
|
|
23
|
+
gh = f"https://github.com/{repo}"
|
|
24
|
+
dora = (delivery or {}).get("dora") or {}
|
|
25
|
+
ev = dora.get("evidence") or {}
|
|
26
|
+
if what == "deployments":
|
|
27
|
+
rows = ev.get("deployments") or []
|
|
28
|
+
return {"headline": f"Deployments: {dora.get('deployments', len(rows))} in {(delivery or {}).get('window_days', '?')} days",
|
|
29
|
+
"meaning": "Every deployment counted, oldest first; deployment frequency counts the days they landed on.",
|
|
30
|
+
"items": [{"commit": c, "finished": t, "url": f"{gh}/commit/{c}"} for c, t in rows], "total": len(rows)}
|
|
31
|
+
if what == "lead-time":
|
|
32
|
+
rows = ev.get("lead_time") or []
|
|
33
|
+
return {"headline": f"Change lead time: {dora.get('lead_time_hours', '?')} h (median of {dora.get('lead_time_n', len(rows))} deployed pull requests)",
|
|
34
|
+
"meaning": "Each deployed pull request with its time from first commit to deployment, longest first.",
|
|
35
|
+
"items": [{"pr": n, "deployment": c, "time": _hours(m), "url": f"{gh}/pull/{n}"} for n, c, m in rows],
|
|
36
|
+
"total": ev.get("lead_time_total", len(rows))}
|
|
37
|
+
if what in ("change-failure", "recovery"):
|
|
38
|
+
key = "failed" if what == "change-failure" else "recovery"
|
|
39
|
+
rows = ev.get(key) or []
|
|
40
|
+
head = (f"Change fail rate: {_pct(dora.get('change_failure_rate'))} ({dora.get('failed_deployments', len(rows))} of {dora.get('deployments', '?')} deployments)"
|
|
41
|
+
if key == "failed" else f"Failed deployment recovery: {dora.get('recovery_hours', '?')} h (median of {dora.get('recovery_n', len(rows))})")
|
|
42
|
+
kind = lambda s: "issues" if s == "issue" else "pull" # noqa: E731
|
|
43
|
+
items = [{"deployment": r[0], "incident": f"{r[1]} #{r[2]}", **({"time": _hours(r[3])} if key == "recovery" else {}),
|
|
44
|
+
"url": f"{gh}/{kind(r[1])}/{r[2]}" if r[2] and r[1] in ("issue", "revert", "hotfix") else f"{gh}/commit/{r[0]}"} for r in rows]
|
|
45
|
+
return {"headline": head, "meaning": "Each deployment tied to an incident after it (a revert, a hotfix or an incident issue)"
|
|
46
|
+
+ ("." if key == "failed" else ", with the time until it was resolved."), "items": items, "total": len(rows)}
|
|
47
|
+
if what == "first-try":
|
|
48
|
+
p = (data or {}).get("pushes") or {}
|
|
49
|
+
rows = p.get("not_first_try") or []
|
|
50
|
+
return {"headline": f"Green on the first try: {_pct(p.get('first_try_share'))} of {p.get('pushes', '?')} pushes",
|
|
51
|
+
"meaning": "The pushes whose checks failed or needed a re-run, newest first, with those runs.",
|
|
52
|
+
"items": [{"commit": c, "branch": b, "runs": ids, "url": f"{gh}/actions/runs/{ids[0]}" if ids else f"{gh}/commit/{c}"}
|
|
53
|
+
for c, b, ids in rows], "total": p.get("not_first_try_total", len(rows))}
|
|
54
|
+
if what in PHASES:
|
|
55
|
+
ph = ((delivery or {}).get("phases") or {}).get(what.replace("-", "_")) or {}
|
|
56
|
+
rows = ph.get("evidence") or []
|
|
57
|
+
p75 = f"P75 {ph['p75_h']} h" if ph.get("p75_h") is not None else "too few to read a P75"
|
|
58
|
+
return {"headline": f"{what.replace('-', ' ').capitalize()}: {p75} over {ph.get('n', len(rows))} merged pull requests",
|
|
59
|
+
"meaning": "Each merged pull request's hours in this phase, longest first.",
|
|
60
|
+
"items": [{"pr": n, "time": f"{h:.1f} h", "url": f"{gh}/pull/{n}"} for n, h in rows], "total": ph.get("n", len(rows))}
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _read(docs: Path, name: str) -> dict | None:
|
|
65
|
+
try:
|
|
66
|
+
return json.loads((docs / name).read_text(encoding="utf-8"))
|
|
67
|
+
except (OSError, ValueError):
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main(argv: list[str] | None = None) -> int:
|
|
72
|
+
ap = argparse.ArgumentParser(prog="stackvitals explain", description=__doc__.split("\n\n")[0])
|
|
73
|
+
ap.add_argument("repo", help="owner/name")
|
|
74
|
+
ap.add_argument("number", help=", ".join(NUMBERS))
|
|
75
|
+
ap.add_argument("--docs", help="a docs folder to read instead of the repo's data branch")
|
|
76
|
+
ap.add_argument("--limit", type=int, default=20, help="items to print (default 20)")
|
|
77
|
+
ap.add_argument("--json", action="store_true")
|
|
78
|
+
args = ap.parse_args(argv)
|
|
79
|
+
if args.number not in NUMBERS:
|
|
80
|
+
print(f"Can't explain {args.number!r}. Pick one of: {', '.join(NUMBERS)}.", file=sys.stderr)
|
|
81
|
+
return 2
|
|
82
|
+
if args.docs:
|
|
83
|
+
docs = Path(args.docs)
|
|
84
|
+
else:
|
|
85
|
+
from stackvitals.ci import cli as ci_cli
|
|
86
|
+
from stackvitals.core.uicli import fetch
|
|
87
|
+
folder = Path(ci_cli.home()) / "ui" / args.repo.replace("/", "__")
|
|
88
|
+
try:
|
|
89
|
+
docs = fetch(args.repo, ci_cli.token(), folder) / "docs"
|
|
90
|
+
except RuntimeError as exc:
|
|
91
|
+
print(f"Could not read {args.repo}: {exc}", file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
out = explain(args.repo, args.number, _read(docs, "delivery.json"), _read(docs, "data.json"))
|
|
94
|
+
if not out or not out["items"]:
|
|
95
|
+
print(f"{args.repo} hasn't published what's behind {args.number} yet (the next scheduled run writes it).", file=sys.stderr)
|
|
96
|
+
return 1
|
|
97
|
+
if args.json:
|
|
98
|
+
print(json.dumps(out, indent=1))
|
|
99
|
+
return 0
|
|
100
|
+
shown = out["items"][:args.limit]
|
|
101
|
+
print(out["headline"])
|
|
102
|
+
print(out["meaning"])
|
|
103
|
+
for it in shown:
|
|
104
|
+
print(" " + " ".join(f"#{v}" if k == "pr" else str(v) for k, v in it.items() if k != "url") + f" {it['url']}")
|
|
105
|
+
if out["total"] > len(shown):
|
|
106
|
+
print(f"({len(shown)} of {out['total']} shown; --limit for more, --json for all published)")
|
|
107
|
+
return 0
|