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,57 @@
|
|
|
1
|
+
"""Effort and capitalisation for finance: one row per merged pull request into the default branch (month, link, kind of
|
|
2
|
+
work and how it was decided, its epic or project when Jira or Linear is connected, written by a coding agent or a person)
|
|
3
|
+
as a CSV an auditor can follow back to GitHub, and a monthly summary of author-weeks by kind of work with the agent share.
|
|
4
|
+
Feature work is what teams usually capitalise; the rule stays the team's and its auditor's, these are the facts behind it."""
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
|
|
10
|
+
from stackvitals.core.ai_outcomes import mode
|
|
11
|
+
from stackvitals.core.investment import classify
|
|
12
|
+
|
|
13
|
+
COLUMNS = ("month", "merged_at", "pr", "url", "title", "kind", "decided_by", "epic", "author", "author_kind")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def rows(prs: list[dict], repo: str, now: datetime, private: bool, base: str | None = None, months: int = 12,
|
|
17
|
+
rules: dict | None = None, tracker_types: dict | None = None, epics: dict | None = None) -> list[dict]:
|
|
18
|
+
"""Merged PRs of the last `months` months, oldest first; a public repo's rows carry no titles."""
|
|
19
|
+
since = (now - timedelta(days=31 * months)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
20
|
+
out = []
|
|
21
|
+
for p in sorted((p for p in prs if p.get("mg") and p["mg"] >= since and (not base or not p.get("base") or p["base"] == base)),
|
|
22
|
+
key=lambda p: p["mg"]):
|
|
23
|
+
kind, how = classify(p, rules, (tracker_types or {}).get(p["n"]))
|
|
24
|
+
row = {"month": p["mg"][:7], "merged_at": p["mg"], "pr": p["n"], "url": f"https://github.com/{repo}/pull/{p['n']}",
|
|
25
|
+
"kind": kind, "decided_by": how, "epic": (epics or {}).get(p["n"]) or "",
|
|
26
|
+
"author": "agent" if mode(p) == "agent" else "person", "author_kind": mode(p),
|
|
27
|
+
"_login": (p.get("au") or "").lower()} # for author-weeks only; never written to the file
|
|
28
|
+
if private:
|
|
29
|
+
row["title"] = (p.get("ti") or "")[:200]
|
|
30
|
+
out.append(row)
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def to_csv(rows: list[dict]) -> str:
|
|
35
|
+
buf = io.StringIO()
|
|
36
|
+
w = csv.DictWriter(buf, fieldnames=[c for c in COLUMNS if not rows or c in rows[0]], extrasaction="ignore",
|
|
37
|
+
lineterminator="\n")
|
|
38
|
+
w.writeheader()
|
|
39
|
+
w.writerows(rows)
|
|
40
|
+
return buf.getvalue()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def summary(rows: list[dict]) -> list[dict]:
|
|
44
|
+
"""Per month: merged PRs, author-weeks by kind of work (one author's PRs of one kind in one ISO week count once),
|
|
45
|
+
the feature share and how many PRs coding agents wrote."""
|
|
46
|
+
months = defaultdict(lambda: {"prs": 0, "agent_prs": 0, "weeks": defaultdict(set), "kinds": defaultdict(int)})
|
|
47
|
+
for r in rows:
|
|
48
|
+
m = months[r["month"]]
|
|
49
|
+
m["prs"] += 1
|
|
50
|
+
m["agent_prs"] += r["author"] == "agent"
|
|
51
|
+
m["kinds"][r["kind"]] += 1
|
|
52
|
+
week = datetime.fromisoformat(r["merged_at"].replace("Z", "+00:00")).isocalendar()[:2]
|
|
53
|
+
m["weeks"][r["kind"]].add((r.get("_login"), week))
|
|
54
|
+
return [{"month": month, "prs": m["prs"], "agent_prs": m["agent_prs"], "kinds": dict(m["kinds"]),
|
|
55
|
+
"author_weeks": {k: len(v) for k, v in m["weeks"].items()},
|
|
56
|
+
"feature_share": round(m["kinds"].get("feature", 0) / m["prs"], 4) if m["prs"] else None}
|
|
57
|
+
for month, m in sorted(months.items())]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Change risk before merge, from this repo's own history: which merged pull requests caused trouble (later reverted, or
|
|
2
|
+
shipped in a small deployment that then failed), how often changes to the same folders, of the same size, by someone
|
|
3
|
+
new to those folders, without test changes or by a coding agent did, and a score with its reasons for an open one.
|
|
4
|
+
A backtest on the newest pull requests says how much the score is worth here."""
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
|
|
10
|
+
from stackvitals.core.ai_outcomes import BATCH_MAX, _band, is_revert, mode, revert_target
|
|
11
|
+
|
|
12
|
+
MIN_TROUBLED = 5 # fewer troubled pull requests and there is no score, not a guess
|
|
13
|
+
MIN_PRS = 30
|
|
14
|
+
PRIOR = 5 # each factor's rate is pulled toward the repo's base rate as if it had 5 more ordinary PRs
|
|
15
|
+
NESTED = {"src", "lib", "libs", "packages", "apps", "services", "pkg", "internal", "app", "cmd"}
|
|
16
|
+
TESTS = ("test", "spec", "__tests__")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def troubled(rows: list, incidents: list[dict]) -> set[int]:
|
|
20
|
+
"""Merged PRs a later revert undid, or that shipped in a small deployment (5 PRs or fewer) that failed."""
|
|
21
|
+
by_number = {p["n"]: p for p, _ph, _d in rows}
|
|
22
|
+
out = {n for p, _ph, _d in rows if is_revert(p) and (n := revert_target(p, by_number)) is not None}
|
|
23
|
+
batch = defaultdict(int)
|
|
24
|
+
for _p, _ph, d in rows:
|
|
25
|
+
if d:
|
|
26
|
+
batch[d["id"]] += 1
|
|
27
|
+
failed = {i.get("deployment") for i in incidents if i.get("deployment")}
|
|
28
|
+
out |= {p["n"] for p, _ph, d in rows if d and d["id"] in failed and batch[d["id"]] <= BATCH_MAX}
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
FIX = re.compile(r"\b(fix|fixes|fixed|bug|bugfix|hotfix|regression)\b", re.I)
|
|
33
|
+
FOLLOW_UP_DAYS = 14
|
|
34
|
+
COMMON = 0.2 # a file most PRs touch (a lockfile, the changelog) links nothing
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _when(text: str) -> datetime:
|
|
38
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def follow_up_fixes(prs: list[dict], days: int = FOLLOW_UP_DAYS) -> set[int]:
|
|
42
|
+
"""Merged PRs that a fix (by its title or a label) changing the same files followed within `days`: the lightweight
|
|
43
|
+
form of SZZ, the standard way to find changes that needed fixing. Files most PRs touch are ignored."""
|
|
44
|
+
merged = sorted((p for p in prs if p.get("mg") and p.get("paths")), key=lambda p: p["mg"])
|
|
45
|
+
if not merged:
|
|
46
|
+
return set()
|
|
47
|
+
touched = defaultdict(int)
|
|
48
|
+
for p in merged:
|
|
49
|
+
for path in set(p["paths"]):
|
|
50
|
+
touched[path] += 1
|
|
51
|
+
common = {f for f, n in touched.items() if n > COMMON * len(merged)}
|
|
52
|
+
is_fix = lambda p: bool(FIX.search(p.get("ti") or "") or any(FIX.search(x) for x in p.get("lb") or [])) # noqa: E731
|
|
53
|
+
out = set()
|
|
54
|
+
for f in (p for p in merged if is_fix(p)):
|
|
55
|
+
files, end = set(f["paths"]) - common, _when(f["mg"])
|
|
56
|
+
if not files:
|
|
57
|
+
continue
|
|
58
|
+
for p in merged:
|
|
59
|
+
if p["n"] != f["n"] and not is_fix(p) and end - timedelta(days=days) <= _when(p["mg"]) < end \
|
|
60
|
+
and files & set(p["paths"]):
|
|
61
|
+
out.add(p["n"])
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _dirs(p: dict) -> set[str]:
|
|
66
|
+
out = set()
|
|
67
|
+
for path in p.get("paths") or []:
|
|
68
|
+
parts = path.split("/")
|
|
69
|
+
if len(parts) < 2:
|
|
70
|
+
out.add("(root)")
|
|
71
|
+
else:
|
|
72
|
+
depth = 2 if parts[0] in NESTED and len(parts) > 2 else 1
|
|
73
|
+
out.add("/".join(parts[:depth]) + "/")
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _tests(p: dict) -> bool:
|
|
78
|
+
return any(any(t in path.lower() for t in TESTS) for path in p.get("paths") or [])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _facts(p: dict, seen: dict) -> dict:
|
|
82
|
+
dirs = _dirs(p)
|
|
83
|
+
who = (p.get("au") or "").lower()
|
|
84
|
+
return {"dirs": dirs, "band": _band(p), "new": bool(dirs) and not (dirs & seen.get(who, set())),
|
|
85
|
+
"tests": _tests(p), "agent": mode(p) == "agent"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def fit(prs: list[dict], bad: set[int]) -> dict | None:
|
|
89
|
+
"""Each factor's smoothed troubled rate, learnt from merged PRs oldest first (familiarity as it was at the time)."""
|
|
90
|
+
prs = sorted((p for p in prs if p.get("mg") and not is_revert(p)), key=lambda p: p["mg"])
|
|
91
|
+
if len(prs) < MIN_PRS or sum(1 for p in prs if p["n"] in bad) < MIN_TROUBLED:
|
|
92
|
+
return None
|
|
93
|
+
base = sum(1 for p in prs if p["n"] in bad) / len(prs)
|
|
94
|
+
counts = defaultdict(lambda: [0, 0]) # factor -> [PRs, troubled]
|
|
95
|
+
seen: dict[str, set] = defaultdict(set)
|
|
96
|
+
for p in prs:
|
|
97
|
+
f, hit = _facts(p, seen), p["n"] in bad
|
|
98
|
+
for key in [f"dir:{d}" for d in f["dirs"]] + [f"band:{f['band']}", f"new:{f['new']}", f"tests:{f['tests']}",
|
|
99
|
+
f"agent:{f['agent']}"]:
|
|
100
|
+
counts[key][0] += 1
|
|
101
|
+
counts[key][1] += hit
|
|
102
|
+
seen[(p.get("au") or "").lower()] |= f["dirs"]
|
|
103
|
+
rates = {k: (t + PRIOR * base) / (n + PRIOR) for k, (n, t) in counts.items()}
|
|
104
|
+
return {"base": base, "rates": rates, "counts": {k: v for k, v in counts.items()}, "seen": seen, "prs": len(prs)}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _odds(p: float) -> float:
|
|
108
|
+
p = min(max(p, 1e-4), 1 - 1e-4)
|
|
109
|
+
return p / (1 - p)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def score(p: dict, model: dict | None) -> dict | None:
|
|
113
|
+
"""{"risk", "level", "reasons"} for one PR; None without a model."""
|
|
114
|
+
if not model:
|
|
115
|
+
return None
|
|
116
|
+
base, rates, counts = model["base"], model["rates"], model["counts"]
|
|
117
|
+
f = _facts(p, model["seen"])
|
|
118
|
+
dir_keys = [f"dir:{d}" for d in f["dirs"] if f"dir:{d}" in rates]
|
|
119
|
+
# The riskiest folder speaks for the change; other factors are combined as odds against the base rate.
|
|
120
|
+
keys = ([max(dir_keys, key=lambda k: rates[k])] if dir_keys else []) + \
|
|
121
|
+
[k for k in (f"band:{f['band']}", f"new:{f['new']}", f"tests:{f['tests']}", f"agent:{f['agent']}") if k in rates]
|
|
122
|
+
log_odds = math.log(_odds(base)) + sum(math.log(_odds(rates[k]) / _odds(base)) for k in keys)
|
|
123
|
+
risk = 1 / (1 + math.exp(-log_odds))
|
|
124
|
+
reasons = []
|
|
125
|
+
for k in keys:
|
|
126
|
+
if rates[k] < 1.5 * base:
|
|
127
|
+
continue
|
|
128
|
+
n, t = counts[k]
|
|
129
|
+
kind, value = k.split(":", 1)
|
|
130
|
+
reasons.append({"dir": f"{value}: {t} of {n} changes here caused trouble",
|
|
131
|
+
"band": f"a change of about {(p.get('add') or 0) + (p.get('del') or 0):,} lines ({t} of {n} this size did)",
|
|
132
|
+
"new": "the author's first change in these folders",
|
|
133
|
+
"tests": "no test files changed" if value == "False" else "test files changed",
|
|
134
|
+
"agent": "written by a coding agent"}[kind])
|
|
135
|
+
level = "high" if risk >= 2 * base and risk >= 0.1 else "medium" if risk >= 1.3 * base else "low"
|
|
136
|
+
return {"risk": round(risk, 4), "level": level, "base": round(base, 4), "reasons": reasons}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _auc(scored: list[tuple[float, bool]]) -> float | None:
|
|
140
|
+
pos = [s for s, y in scored if y]
|
|
141
|
+
neg = [s for s, y in scored if not y]
|
|
142
|
+
if not pos or not neg:
|
|
143
|
+
return None
|
|
144
|
+
wins = sum((a > b) + 0.5 * (a == b) for a in pos for b in neg)
|
|
145
|
+
return wins / (len(pos) * len(neg))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def backtest(prs: list[dict], bad: set[int], share: float = 0.7) -> dict | None:
|
|
149
|
+
"""Learn from the oldest 70% of merged PRs and score the newest 30%: how well the score ranks the troubled ones
|
|
150
|
+
(AUC, 0.5 = no better than chance) and the trouble rate among the top fifth scored."""
|
|
151
|
+
merged = sorted((p for p in prs if p.get("mg") and not is_revert(p)), key=lambda p: p["mg"])
|
|
152
|
+
cut = int(len(merged) * share)
|
|
153
|
+
train, test = merged[:cut], merged[cut:]
|
|
154
|
+
model = fit(train, bad)
|
|
155
|
+
hits = [p for p in test if p["n"] in bad]
|
|
156
|
+
if not model or len(hits) < 3:
|
|
157
|
+
return None
|
|
158
|
+
scored = sorted(((score(p, model)["risk"], p["n"] in bad) for p in test), key=lambda x: -x[0])
|
|
159
|
+
top = scored[:max(1, len(scored) // 5)]
|
|
160
|
+
return {"train": len(train), "test": len(test), "test_troubled": len(hits), "auc": round(_auc(scored), 3),
|
|
161
|
+
"top_fifth_trouble": round(sum(y for _s, y in top) / len(top), 3),
|
|
162
|
+
"overall_trouble": round(len(hits) / len(test), 3)}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
MIN_AUC = 0.65 # below this, this repo's own backtest says the score isn't worth showing
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def assess(prs: list[dict], bad: set[int], open_prs: list[dict], top: int = 50) -> dict:
|
|
169
|
+
"""The backtest first; open PRs scored (riskiest first) only when it shows the score ranks trouble here."""
|
|
170
|
+
bt = backtest(prs, bad)
|
|
171
|
+
if not bt:
|
|
172
|
+
return {"backtest": None, "open": None, "troubled": len(bad),
|
|
173
|
+
"note": "Not enough history yet: it needs about 30 merged pull requests and a few that needed a fix."}
|
|
174
|
+
if bt["auc"] < MIN_AUC:
|
|
175
|
+
return {"backtest": bt, "open": None, "troubled": len(bad),
|
|
176
|
+
"note": f"Not predictive in this repo: on its newest pull requests it ranked trouble about as well as chance "
|
|
177
|
+
f"(AUC {bt['auc']:.2f}), so no score is shown."}
|
|
178
|
+
model = fit(prs, bad)
|
|
179
|
+
scored = [{"pr": p.get("n"), **score(p, model)} for p in open_prs]
|
|
180
|
+
scored.sort(key=lambda s: -s["risk"])
|
|
181
|
+
return {"backtest": bt, "open": scored[:top], "troubled": len(bad), "note": None}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Week against baseline: the latest seven days of a number against the four weeks before them, with n on both sides
|
|
2
|
+
and a p-value, so the digest flags only the changes that survive false-alarm control across everything it checked."""
|
|
3
|
+
import math
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from stackvitals.core import stats
|
|
7
|
+
from stackvitals.core.time import iso
|
|
8
|
+
|
|
9
|
+
WEEK, BASE_WEEKS = timedelta(days=7), 4
|
|
10
|
+
HISTORY_WEEKS = 8 # past weeks that show how much this repo's weeks usually swing
|
|
11
|
+
# The typical swing before a repo's own history is known, worth 4 weeks: review times swing 1.5 to 2 times what
|
|
12
|
+
# independent PRs would (20 public repos; 1.5 chosen on half of them, checked on the other half); shares and counts 1.
|
|
13
|
+
PRIOR_SPREAD, PRIOR_WEEKS = {"share": 1.0, "count": 1.0}, 4
|
|
14
|
+
TIME_SPREAD = 1.5
|
|
15
|
+
# One wild past week (a backlog merged at once) counts as at most a four-sigma swing: the same catches as three sigma
|
|
16
|
+
# on 29 public repos, with fewer false flags (7.6% and 8.8% of shuffled scans on each half against 7.9% and 9.7%).
|
|
17
|
+
Z2_CAP = 16.0
|
|
18
|
+
MIN_EACH = 5 # fewer on either side and a shift says nothing
|
|
19
|
+
Q = 0.1 # the share of flagged changes allowed to be false alarms, across one scan
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def windows(now: datetime, weeks: int = 1) -> tuple[tuple[str, str], tuple[str, str]]:
|
|
23
|
+
"""(the last `weeks` weeks, the four weeks before them) as ISO bounds, start included and end left out."""
|
|
24
|
+
cut = now - WEEK * weeks
|
|
25
|
+
return (iso(cut), iso(now)), (iso(cut - WEEK * BASE_WEEKS), iso(cut))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _row(metric: str, label: str, now, base, n_now: int, n_base: int, p: float, better: int, unit: str,
|
|
29
|
+
note: str | None = None, **extra) -> dict:
|
|
30
|
+
direction = None if now is None or base is None else "same" if now == base else "up" if now > base else "down"
|
|
31
|
+
worse = direction in ("up", "down") and better != 0 and (direction == "up") == (better < 0)
|
|
32
|
+
return {"metric": metric, "label": label, "now": now, "base": base, "n_now": n_now, "n_base": n_base,
|
|
33
|
+
"p": round(p, 6), "direction": direction, "better": better, "worse": worse, "unit": unit, "note": note,
|
|
34
|
+
**extra}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def share(metric: str, label: str, k_now: int, n_now: int, k_base: int, n_base: int, better: int) -> dict:
|
|
38
|
+
"""A share this week against the weeks before (Fisher's exact test)."""
|
|
39
|
+
enough = min(n_now, n_base) >= MIN_EACH
|
|
40
|
+
p = stats.fisher(k_now, n_now, k_base, n_base) if enough else 1.0
|
|
41
|
+
value = lambda k, n: round(k / n, 4) if n else None # noqa: E731
|
|
42
|
+
return _row(metric, label, value(k_now, n_now), value(k_base, n_base), n_now, n_base, p, better, "share",
|
|
43
|
+
None if enough else "too few to tell")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def times(metric: str, label: str, now_values: list[float], base_values: list[float], better: int, unit: str,
|
|
47
|
+
span: int = 1) -> dict:
|
|
48
|
+
"""A duration over the last `span` weeks against the four before (medians shown, Mann-Whitney tested)."""
|
|
49
|
+
a, b = [v for v in now_values if v is not None], [v for v in base_values if v is not None]
|
|
50
|
+
enough = min(len(a), len(b)) >= MIN_EACH
|
|
51
|
+
p = stats.mann_whitney(a, b) if enough else 1.0
|
|
52
|
+
mid = lambda v: None if not v else round(stats.median(v), 2) # noqa: E731
|
|
53
|
+
logs = [math.log(max(v, 1 / 60)) for v in b] # a minute's floor: instant merges would make the log endless
|
|
54
|
+
log_sd = round(stats.stdev(logs), 4) if len(logs) > 1 else None
|
|
55
|
+
return _row(metric, label, mid(a), mid(b), len(a), len(b), p, better, unit, None if enough else "too few to tell",
|
|
56
|
+
log_sd=log_sd, span=span)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def count(metric: str, label: str, now_count: int, base_count: int, better: int) -> dict:
|
|
60
|
+
"""A weekly count against the weekly average of the weeks before (binomial on the combined count)."""
|
|
61
|
+
enough = now_count + base_count >= MIN_EACH
|
|
62
|
+
p = stats.count_change(now_count, base_count, BASE_WEEKS) if enough else 1.0
|
|
63
|
+
return _row(metric, label, now_count, round(base_count / BASE_WEEKS, 2), now_count, base_count, p, better, "count",
|
|
64
|
+
None if enough else "too few to tell")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def calibrate(checks_at, now: datetime, weeks: int = HISTORY_WEEKS) -> list[dict]:
|
|
68
|
+
"""This week's checks with each p-value judged against the same check in the repo's past weeks.
|
|
69
|
+
|
|
70
|
+
Work arrives in batches (a busy review day, a release week), so events in one week are not independent and a
|
|
71
|
+
plain test overstates certainty. Each check is also run for each past week against its own four before it; how
|
|
72
|
+
widely those results swing (their mean squared z, blended with a typical swing worth four weeks) scales this
|
|
73
|
+
week's z, read on a t-distribution with one degree of freedom per week of evidence (an empirical null).
|
|
74
|
+
A check over several weeks is judged against past windows that don't overlap it, so an ongoing slowdown doesn't
|
|
75
|
+
count as the repo's usual swing."""
|
|
76
|
+
current = checks_at(now)
|
|
77
|
+
longest = max((c.get("span", 1) for c in current), default=1)
|
|
78
|
+
past = {j: checks_at(now - WEEK * j) for j in range(1, weeks + longest)}
|
|
79
|
+
out = []
|
|
80
|
+
for i, c in enumerate(current):
|
|
81
|
+
span = c.get("span", 1)
|
|
82
|
+
z2 = [min(stats.normal_isf(week[i]["p"]) ** 2, Z2_CAP) for week in (past[j] for j in range(span, span + weeks))
|
|
83
|
+
if i < len(week) and week[i]["note"] is None]
|
|
84
|
+
if c["note"] is None:
|
|
85
|
+
prior = PRIOR_SPREAD.get(c["unit"], TIME_SPREAD)
|
|
86
|
+
spread = max(1.0, (sum(z2) + PRIOR_WEEKS * prior) / (len(z2) + PRIOR_WEEKS))
|
|
87
|
+
p = stats.t_two_sided(stats.normal_isf(c["p"]) / math.sqrt(spread), len(z2) + PRIOR_WEEKS)
|
|
88
|
+
c = {**c, "p": round(p, 6), "p_raw": c["p"], "spread": round(spread, 2), "weeks_seen": len(z2),
|
|
89
|
+
"df": len(z2) + PRIOR_WEEKS, "scale": _scale(c, spread)}
|
|
90
|
+
out.append(c)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _scale(c: dict, spread: float) -> float | None:
|
|
95
|
+
# One standard error of the change on its own scale (log time, or share), widened by the repo's usual swing.
|
|
96
|
+
if c["unit"] == "count" or not c["n_now"] or not c["n_base"]:
|
|
97
|
+
return None
|
|
98
|
+
both = math.sqrt(1 / c["n_now"] + 1 / c["n_base"]) * math.sqrt(spread)
|
|
99
|
+
if c["unit"] == "share":
|
|
100
|
+
pb = min(max(c["base"] or 0, 0.02), 0.98)
|
|
101
|
+
return round(math.sqrt(pb * (1 - pb)) * both, 5)
|
|
102
|
+
if not c.get("log_sd"):
|
|
103
|
+
return None
|
|
104
|
+
return round(c["log_sd"] * both * 1.023, 5) # rank tests on skewed times lose about 2% against the ideal test
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def detectable(c: dict, run: int, q: float = Q) -> float | None:
|
|
108
|
+
"""The change this check would have caught with about 80% chance in a scan of `run` checks: a factor for times,
|
|
109
|
+
points of share for shares, None for counts."""
|
|
110
|
+
if c.get("scale") is None:
|
|
111
|
+
return None
|
|
112
|
+
z = stats.t_isf(q / max(run, 1), c["df"]) + 0.8416
|
|
113
|
+
return round(math.exp(z * c["scale"]) if c["unit"] not in ("share", "count") else z * c["scale"], 4)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def flag(found: list[dict], q: float = Q) -> list[dict]:
|
|
117
|
+
"""Every change with `changed` set: True only for those surviving Benjamini-Hochberg over the checks actually run
|
|
118
|
+
in the scan (one with too few items to tell is not a test, so it doesn't count against the others)."""
|
|
119
|
+
run = [i for i, c in enumerate(found) if c.get("note") is None]
|
|
120
|
+
keep = {i for i, k in zip(run, stats.bh([found[i]["p"] for i in run], q)) if k}
|
|
121
|
+
# A pairing from an earlier pass (a module's own) is decided again here, over this scan's checks.
|
|
122
|
+
out = [{**{k: v for k, v in c.items() if k != "shown_with"}, "changed": i in keep and c["direction"] in ("up", "down")}
|
|
123
|
+
for i, c in enumerate(found)]
|
|
124
|
+
# A slowdown flagged both this week and over 14 days is one finding: the 14-day row is shown with the weekly one.
|
|
125
|
+
weekly = {c["metric"]: c for c in out if c["changed"]}
|
|
126
|
+
return [{**c, "shown_with": c["metric"][:-4]} if c["metric"].endswith("_14d") and c["changed"]
|
|
127
|
+
and weekly.get(c["metric"][:-4], {}).get("direction") == c["direction"] else c for c in out]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def annotate(found: list[dict], q: float = Q) -> list[dict]:
|
|
131
|
+
"""A module's checks as its page shows them: flagged within the module, each with the change it could have caught."""
|
|
132
|
+
run = sum(1 for c in found if c.get("note") is None)
|
|
133
|
+
return [{**c, "detectable": detectable(c, run, q) if c.get("note") is None else None} for c in flag(found, q)]
|
stackvitals/core/chat.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Chat messages in each chat's own shape: Slack Block Kit with link buttons (and Slack's own markdown), a Teams Adaptive
|
|
2
|
+
Card with open-link actions (Teams Workflows), a Discord embed, or plain JSON for anything else; and which webhook a
|
|
3
|
+
team's message goes to. Buttons only open links: nothing here needs a server to answer clicks."""
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
BOLD = re.compile(r"\*\*(.+?)\*\*")
|
|
9
|
+
LINK = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)")
|
|
10
|
+
TEAM_LINE = re.compile(r"^\s*(@?[\w.-]+(?:/[\w.-]+)?)\s*=\s*(https://\S+)\s*$")
|
|
11
|
+
SLACK_SECTION, SLACK_BLOCKS, SLACK_BUTTONS, DISCORD_TEXT = 3000, 50, 5, 4096
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def message(title: str, lines: list[str], links=(), data: dict | None = None) -> dict:
|
|
15
|
+
"""What every chat gets: a title, Markdown lines (**bold**, `code`, [text](url)), up to five link buttons, and the
|
|
16
|
+
numbers for plain JSON webhooks."""
|
|
17
|
+
return {"title": title, "lines": list(lines), "links": list(links), "data": data or {}}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def safe(name, size: int = 200) -> str:
|
|
21
|
+
"""A name from the repo (a test, a job, a package) as inert text: one line that can't become a link (no chat pings plain @names)."""
|
|
22
|
+
out = " ".join(str(name).split())[:size].replace("`", "'")
|
|
23
|
+
return out.replace("](", "]\u200b(").replace("://", ":\u200b//")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def kind(url: str) -> str:
|
|
27
|
+
if "hooks.slack.com" in url:
|
|
28
|
+
return "slack"
|
|
29
|
+
if "discord.com/api/webhooks" in url or "discordapp.com/api/webhooks" in url:
|
|
30
|
+
return "discord"
|
|
31
|
+
if "logic.azure.com" in url or "webhook.office.com" in url or "powerplatform.com" in url:
|
|
32
|
+
return "teams"
|
|
33
|
+
return "json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def text(msg: dict) -> str:
|
|
37
|
+
"""The message as plain lines (job summaries, fallbacks)."""
|
|
38
|
+
return "\n".join([msg["title"], *msg["lines"]])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _slack_escape(text: str) -> str:
|
|
42
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _slack_md(text: str) -> str:
|
|
46
|
+
text = _slack_escape(text)
|
|
47
|
+
return LINK.sub(r"<\2|\1>", BOLD.sub(r"*\1*", text))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _chunks(lines: list[str], size: int) -> list[str]:
|
|
51
|
+
out, cur = [], ""
|
|
52
|
+
for line in lines:
|
|
53
|
+
line = line[:size]
|
|
54
|
+
if cur and len(cur) + 1 + len(line) > size:
|
|
55
|
+
out.append(cur)
|
|
56
|
+
cur = line
|
|
57
|
+
else:
|
|
58
|
+
cur = f"{cur}\n{line}" if cur else line
|
|
59
|
+
return out + ([cur] if cur else [])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _slack(msg: dict) -> dict:
|
|
63
|
+
blocks = [{"type": "header", "text": {"type": "plain_text", "text": msg["title"][:150]}}]
|
|
64
|
+
sections = _chunks([_slack_md(x) for x in msg["lines"]], SLACK_SECTION)
|
|
65
|
+
room = SLACK_BLOCKS - 1 - (1 if msg["links"] else 0)
|
|
66
|
+
blocks += [{"type": "section", "text": {"type": "mrkdwn", "text": s}} for s in sections[:room]]
|
|
67
|
+
if msg["links"]:
|
|
68
|
+
blocks.append({"type": "actions", "elements": [
|
|
69
|
+
{"type": "button", "text": {"type": "plain_text", "text": label[:75]}, "url": url}
|
|
70
|
+
for label, url in msg["links"][:SLACK_BUTTONS]]})
|
|
71
|
+
# The fallback is read as Slack markup too (notifications), so it is escaped like the sections.
|
|
72
|
+
return {"text": _slack_escape(text(msg))[:3000], "blocks": blocks}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _teams(msg: dict) -> dict:
|
|
76
|
+
card = {"type": "AdaptiveCard", "version": "1.4", "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
|
77
|
+
"body": [{"type": "TextBlock", "text": msg["title"], "weight": "Bolder", "size": "Medium", "wrap": True},
|
|
78
|
+
*({"type": "TextBlock", "text": line, "wrap": True} for line in msg["lines"])],
|
|
79
|
+
"actions": [{"type": "Action.OpenUrl", "title": label, "url": url} for label, url in msg["links"][:SLACK_BUTTONS]]}
|
|
80
|
+
return {"type": "message", "attachments": [{"contentType": "application/vnd.microsoft.card.adaptive", "content": card}]}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _discord(msg: dict) -> dict:
|
|
84
|
+
links = " · ".join(f"[{label}]({url})" for label, url in msg["links"])
|
|
85
|
+
body = "\n".join(msg["lines"])
|
|
86
|
+
room = DISCORD_TEXT - len(links) - 2
|
|
87
|
+
text = (body if len(body) <= room else body[:room - 1] + "…") + (f"\n\n{links}" if links else "")
|
|
88
|
+
return {"embeds": [{"title": msg["title"][:256], "description": text[:DISCORD_TEXT],
|
|
89
|
+
**({"url": msg["links"][0][1]} if msg["links"] else {})}], "allowed_mentions": {"parse": []}}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def payload(url: str, msg: dict) -> dict:
|
|
93
|
+
shape = kind(url)
|
|
94
|
+
if shape == "slack":
|
|
95
|
+
return _slack(msg)
|
|
96
|
+
if shape == "teams":
|
|
97
|
+
return _teams(msg)
|
|
98
|
+
if shape == "discord":
|
|
99
|
+
return _discord(msg)
|
|
100
|
+
return {"source": "stackvitals", "title": msg["title"], "text": text(msg),
|
|
101
|
+
"links": [{"label": label, "url": url} for label, url in msg["links"]], **msg["data"]}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def post(url: str, msg: dict, client: httpx.Client | None = None, what: str = "the webhook") -> str | None:
|
|
105
|
+
"""Post a message in the chat's own shape; a failure comes back as text for the job summary, never as an exception."""
|
|
106
|
+
url = url.strip() # a secret pasted with a trailing newline
|
|
107
|
+
try:
|
|
108
|
+
response = (client or httpx.Client(timeout=30)).post(url, json=payload(url, msg))
|
|
109
|
+
except (httpx.HTTPError, httpx.InvalidURL) as exc:
|
|
110
|
+
return f"Could not send {what} ({type(exc).__name__})."
|
|
111
|
+
if response.status_code >= 300:
|
|
112
|
+
return f"{what[0].upper()}{what[1:]} answered {response.status_code}."
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def app_url(repo: str, info: dict | None, route: str = "") -> str | None:
|
|
117
|
+
"""The monitor's address on GitHub Pages, when the repo is public and publishes Pages; else None."""
|
|
118
|
+
if not info or info.get("private") or not info.get("has_pages"):
|
|
119
|
+
return None
|
|
120
|
+
owner, name = repo.split("/")
|
|
121
|
+
base = f"https://{owner.lower()}.github.io/" if name.lower() == f"{owner.lower()}.github.io" else f"https://{owner.lower()}.github.io/{name}/"
|
|
122
|
+
return base + (f"#/{route}" if route else "")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def team_hooks(env: dict) -> dict[str, str]:
|
|
126
|
+
"""`STACKVITALS_TEAM_WEBHOOKS`, one `owner = https://...` per line (owners as CODEOWNERS names them, @org/team)."""
|
|
127
|
+
out = {}
|
|
128
|
+
for line in (env.get("STACKVITALS_TEAM_WEBHOOKS") or "").splitlines():
|
|
129
|
+
found = TEAM_LINE.match(line)
|
|
130
|
+
if found:
|
|
131
|
+
out[found.group(1)] = found.group(2)
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def route(owners_by_item: dict[str, list[str]], env: dict) -> dict[str, list[str]]:
|
|
136
|
+
"""Which webhook gets which items: each owning team with its own webhook gets its items, the rest go to the default."""
|
|
137
|
+
teams, default = team_hooks(env), (env.get("STACKVITALS_WEBHOOK") or "").strip()
|
|
138
|
+
out = {}
|
|
139
|
+
for item, owners in owners_by_item.items():
|
|
140
|
+
hooks = [teams[o] for o in owners or [] if o in teams]
|
|
141
|
+
for hook in dict.fromkeys(hooks or ([default] if default else [])):
|
|
142
|
+
out.setdefault(hook, []).append(item)
|
|
143
|
+
return out
|