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,222 @@
|
|
|
1
|
+
"""The suite's only statistics helpers: every module computes medians, percentiles and ranges the same way."""
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
# One implementation each, shared with the crawler that builds the benchmarks.
|
|
5
|
+
from mergescout.bench.matched import mh_risk_difference # noqa: F401
|
|
6
|
+
from mergescout.bench.prior import beta_fit # noqa: F401
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def median(values: list[float]) -> float | None:
|
|
10
|
+
if not values:
|
|
11
|
+
return None
|
|
12
|
+
s, n = sorted(values), len(values)
|
|
13
|
+
return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def stdev(values: list[float]) -> float | None:
|
|
17
|
+
"""The sample standard deviation, or None under two values."""
|
|
18
|
+
if len(values) < 2:
|
|
19
|
+
return None
|
|
20
|
+
m = sum(values) / len(values)
|
|
21
|
+
return math.sqrt(sum((v - m) ** 2 for v in values) / (len(values) - 1))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def percentile(values: list[float], q: float) -> float | None:
|
|
25
|
+
# Nearest rank: the value at ceil(q * n), never interpolated, so small samples show real values.
|
|
26
|
+
if not values:
|
|
27
|
+
return None
|
|
28
|
+
s = sorted(values)
|
|
29
|
+
return s[min(len(s) - 1, max(0, math.ceil(q * len(s)) - 1))]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
|
|
33
|
+
"""A 95% range for a share of k in n; no trials means anything from 0 to 1, and k above n clamps instead of crashing."""
|
|
34
|
+
if n <= 0:
|
|
35
|
+
return 0.0, 1.0
|
|
36
|
+
p = min(k, n) / n
|
|
37
|
+
d = 1 + z * z / n
|
|
38
|
+
c = (p + z * z / (2 * n)) / d
|
|
39
|
+
h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
|
|
40
|
+
return max(0.0, c - h), min(1.0, c + h)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
MIN_RANGE = 6
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def quantile_range(values: list[float], q: float, z: float = 1.96) -> tuple[float, float] | None:
|
|
47
|
+
"""A 95% range for a quantile from the values themselves (binomial order statistics), None under 6 values."""
|
|
48
|
+
n = len(values)
|
|
49
|
+
if n < MIN_RANGE:
|
|
50
|
+
return None
|
|
51
|
+
s = sorted(values)
|
|
52
|
+
half = z * math.sqrt(n * q * (1 - q))
|
|
53
|
+
lo = max(0, math.floor(n * q - half))
|
|
54
|
+
hi = min(n - 1, math.ceil(n * q + half))
|
|
55
|
+
return s[lo], s[hi]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def median_range(values: list[float], z: float = 1.96) -> tuple[float, float] | None:
|
|
59
|
+
return quantile_range(values, 0.5, z)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _betacf(a: float, b: float, x: float) -> float:
|
|
63
|
+
# Lentz's continued fraction for the incomplete beta (Numerical Recipes, 6.4).
|
|
64
|
+
tiny = 1e-300
|
|
65
|
+
fix = lambda v: v if abs(v) > tiny else tiny # noqa: E731
|
|
66
|
+
c, d = 1.0, 1 / fix(1 - (a + b) * x / (a + 1))
|
|
67
|
+
h = d
|
|
68
|
+
for m in range(1, 400):
|
|
69
|
+
for aa in (m * (b - m) * x / ((a - 1 + 2 * m) * (a + 2 * m)),
|
|
70
|
+
-(a + m) * (a + b + m) * x / ((a + 2 * m) * (a + 1 + 2 * m))):
|
|
71
|
+
d = 1 / fix(1 + aa * d)
|
|
72
|
+
c = fix(1 + aa / c)
|
|
73
|
+
h *= d * c
|
|
74
|
+
if abs(d * c - 1) < 3e-14:
|
|
75
|
+
break
|
|
76
|
+
return h
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def betainc(a: float, b: float, x: float) -> float:
|
|
80
|
+
"""The regularized incomplete beta I_x(a, b): the chance a Beta(a, b) value is at most x."""
|
|
81
|
+
if x <= 0:
|
|
82
|
+
return 0.0
|
|
83
|
+
if x >= 1:
|
|
84
|
+
return 1.0
|
|
85
|
+
front = math.exp(math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b) + a * math.log(x) + b * math.log1p(-x))
|
|
86
|
+
if x < (a + 1) / (a + b + 2):
|
|
87
|
+
return front * _betacf(a, b, x) / a
|
|
88
|
+
return 1 - front * _betacf(b, a, 1 - x) / b
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def beta_ppf(q: float, a: float, b: float) -> float:
|
|
92
|
+
"""The value a Beta(a, b) stays under with chance q (bisection on betainc)."""
|
|
93
|
+
lo, hi = 0.0, 1.0
|
|
94
|
+
for _ in range(60):
|
|
95
|
+
mid = (lo + hi) / 2
|
|
96
|
+
lo, hi = (mid, hi) if betainc(a, b, mid) < q else (lo, mid)
|
|
97
|
+
return (lo + hi) / 2
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def shrink(k: int, n: int, prior: tuple[float, float]) -> dict:
|
|
101
|
+
"""A share of k in n pulled toward its peers' Beta prior: the posterior mean and its 95% range."""
|
|
102
|
+
a, b = prior[0] + min(k, n), prior[1] + max(0, n - k)
|
|
103
|
+
return {"value": round(a / (a + b), 4), "low": round(beta_ppf(0.025, a, b), 4), "high": round(beta_ppf(0.975, a, b), 4),
|
|
104
|
+
"n": n}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def t_two_sided(t: float, df: float) -> float:
|
|
108
|
+
"""Two-sided p-value of a Student t statistic with df degrees of freedom."""
|
|
109
|
+
return min(1.0, betainc(df / 2, 0.5, df / (df + t * t))) if t else 1.0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def t_isf(p: float, df: float) -> float:
|
|
113
|
+
"""The |t| whose two-sided p-value with df degrees of freedom is p (bisection on t_two_sided)."""
|
|
114
|
+
if p >= 1:
|
|
115
|
+
return 0.0
|
|
116
|
+
lo, hi = 0.0, 1e4
|
|
117
|
+
for _ in range(200):
|
|
118
|
+
mid = (lo + hi) / 2
|
|
119
|
+
lo, hi = (mid, hi) if t_two_sided(mid, df) > p else (lo, mid)
|
|
120
|
+
return (lo + hi) / 2
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def normal_isf(p: float) -> float:
|
|
124
|
+
"""The |z| whose two-sided normal p-value is p (bisection on erfc)."""
|
|
125
|
+
if p >= 1:
|
|
126
|
+
return 0.0
|
|
127
|
+
p = max(p, 1e-300)
|
|
128
|
+
lo, hi = 0.0, 40.0
|
|
129
|
+
for _ in range(100):
|
|
130
|
+
mid = (lo + hi) / 2
|
|
131
|
+
lo, hi = (mid, hi) if math.erfc(mid / math.sqrt(2)) > p else (lo, mid)
|
|
132
|
+
return (lo + hi) / 2
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _log_comb(n: int, k: int) -> float:
|
|
136
|
+
return math.lgamma(n + 1) - math.lgamma(k + 1) - math.lgamma(n - k + 1)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def fisher(k1: int, n1: int, k2: int, n2: int) -> float:
|
|
140
|
+
"""Two-sided p-value that k1 of n1 and k2 of n2 come from the same share (Fisher's exact test)."""
|
|
141
|
+
if n1 <= 0 or n2 <= 0:
|
|
142
|
+
return 1.0
|
|
143
|
+
hits, total = k1 + k2, n1 + n2
|
|
144
|
+
lo, hi = max(0, hits - n2), min(hits, n1)
|
|
145
|
+
log_p = lambda x: _log_comb(n1, x) + _log_comb(n2, hits - x) - _log_comb(total, hits) # noqa: E731
|
|
146
|
+
seen = log_p(k1)
|
|
147
|
+
return min(1.0, sum(math.exp(log_p(x)) for x in range(lo, hi + 1) if log_p(x) <= seen + 1e-7))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def mann_whitney(x: list[float], y: list[float]) -> float:
|
|
151
|
+
"""Two-sided p-value that two samples come from the same distribution (normal approximation, ties averaged)."""
|
|
152
|
+
n1, n2 = len(x), len(y)
|
|
153
|
+
if not n1 or not n2:
|
|
154
|
+
return 1.0
|
|
155
|
+
both = sorted([(v, 0) for v in x] + [(v, 1) for v in y])
|
|
156
|
+
ranks, ties, i = [0.0] * len(both), 0.0, 0
|
|
157
|
+
while i < len(both):
|
|
158
|
+
j = i
|
|
159
|
+
while j + 1 < len(both) and both[j + 1][0] == both[i][0]:
|
|
160
|
+
j += 1
|
|
161
|
+
for t in range(i, j + 1):
|
|
162
|
+
ranks[t] = (i + j) / 2 + 1
|
|
163
|
+
ties += (j - i + 1) ** 3 - (j - i + 1)
|
|
164
|
+
i = j + 1
|
|
165
|
+
n = n1 + n2
|
|
166
|
+
u = sum(r for r, (_v, side) in zip(ranks, both) if side == 0) - n1 * (n1 + 1) / 2
|
|
167
|
+
var = n1 * n2 / 12 * ((n + 1) - ties / (n * (n - 1))) if n > 1 else 0
|
|
168
|
+
if var <= 0:
|
|
169
|
+
return 1.0
|
|
170
|
+
z = max(0.0, abs(u - n1 * n2 / 2) - 0.5) / math.sqrt(var)
|
|
171
|
+
return min(1.0, math.erfc(z / math.sqrt(2)))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def count_change(now: int, before: int, weeks: int) -> float:
|
|
175
|
+
"""Two-sided p-value that this week's count fits the `weeks` weeks before it (binomial on the combined count)."""
|
|
176
|
+
total = now + before
|
|
177
|
+
if total <= 0 or weeks <= 0:
|
|
178
|
+
return 1.0
|
|
179
|
+
p0 = 1 / (weeks + 1)
|
|
180
|
+
log_pmf = lambda x: _log_comb(total, x) + x * math.log(p0) + (total - x) * math.log1p(-p0) # noqa: E731
|
|
181
|
+
seen = log_pmf(now)
|
|
182
|
+
return min(1.0, sum(math.exp(log_pmf(x)) for x in range(total + 1) if log_pmf(x) <= seen + 1e-7))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
KM_MIN = 10
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def km_median(durations: list[float], answered: list[bool], z: float = 1.96) -> dict:
|
|
189
|
+
"""Median time to an answer with items still waiting kept in (Kaplan-Meier): the median and its 95% range
|
|
190
|
+
(Brookmeyer-Crowley on Greenwood's variance), or no median and `at_least` when more than half are still waiting."""
|
|
191
|
+
n, done = len(durations), sum(1 for a in answered if a)
|
|
192
|
+
out = {"median": None, "low": None, "high": None, "at_least": None, "answered": done, "waiting": n - done}
|
|
193
|
+
if n < KM_MIN:
|
|
194
|
+
out["too_few"] = bool(n)
|
|
195
|
+
return out
|
|
196
|
+
s, greenwood, curve = 1.0, 0.0, []
|
|
197
|
+
for t in sorted({d for d, a in zip(durations, answered) if a}):
|
|
198
|
+
at_risk = sum(1 for d in durations if d >= t)
|
|
199
|
+
events = sum(1 for d, a in zip(durations, answered) if a and d == t)
|
|
200
|
+
s *= 1 - events / at_risk
|
|
201
|
+
if at_risk > events:
|
|
202
|
+
greenwood += events / (at_risk * (at_risk - events))
|
|
203
|
+
curve.append((t, s, s * s * greenwood))
|
|
204
|
+
out["median"] = next((t for t, sv, _v in curve if sv <= 0.5), None)
|
|
205
|
+
if out["median"] is None:
|
|
206
|
+
out["at_least"] = max(durations)
|
|
207
|
+
return out
|
|
208
|
+
near = [t for t, sv, var in curve if abs(sv - 0.5) <= z * math.sqrt(var)]
|
|
209
|
+
out["low"], out["high"] = (min(near), max(near)) if near else (out["median"], out["median"])
|
|
210
|
+
# The curve never clearly drops below a half: the range's upper end is beyond what was seen ("or more").
|
|
211
|
+
if curve and abs(curve[-1][1] - 0.5) <= z * math.sqrt(curve[-1][2]):
|
|
212
|
+
out["high"] = None
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def bh(pvalues: list[float], q: float = 0.1) -> list[bool]:
|
|
217
|
+
"""Which p-values survive Benjamini-Hochberg at false-discovery rate q, across everything scanned together."""
|
|
218
|
+
m = len(pvalues)
|
|
219
|
+
order = sorted(range(m), key=lambda i: pvalues[i])
|
|
220
|
+
cut = max((rank for rank, i in enumerate(order, 1) if pvalues[i] <= rank * q / m), default=0)
|
|
221
|
+
keep = set(order[:cut])
|
|
222
|
+
return [i in keep for i in range(m)]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The data branch as a whole: every module's day files, with one retention rule per stream, one size cap that drops the
|
|
2
|
+
oldest days first, a schema number on every published file, and the counter that decides when the git history is
|
|
3
|
+
squashed. The branch is the source of truth; summaries and any database are derived from it."""
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from datetime import date, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
SCHEMA = 1
|
|
11
|
+
META = "stackvitals.json"
|
|
12
|
+
# Stream folder -> days kept (user decision 2026-10-20: 400 for PRs, issues and Portal history, 180 for CI runs). A
|
|
13
|
+
# connector day file holds the newest copy of what changed that day, so dropping it only loses records idle since.
|
|
14
|
+
STREAMS = {"ci/days": 180, "ci/transitions": 400, "flow/closed": 400, "community/issues/closed": 400, "portal/history": 400,
|
|
15
|
+
**{f"connectors/{name}/{part}": 400 for name in ("jira", "linear", "pagerduty", "incident_io") for part in ("days", "raw")},
|
|
16
|
+
"connectors/copilot/days": 730, "connectors/ai_spend/days": 730}
|
|
17
|
+
DEFAULT_CAP_MB = 250
|
|
18
|
+
SQUASH_EVERY = 1000
|
|
19
|
+
DAY_FILE = re.compile(r"^(\d{4}-\d{2}-\d{2})\.(ndjson|ndjson\.gz|json)$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HistoryStore:
|
|
23
|
+
def __init__(self, root: Path):
|
|
24
|
+
self.root = Path(root)
|
|
25
|
+
|
|
26
|
+
def meta(self) -> dict:
|
|
27
|
+
try:
|
|
28
|
+
return json.loads((self.root / META).read_text(encoding="utf-8"))
|
|
29
|
+
except (OSError, ValueError):
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
def save_meta(self, meta: dict) -> None:
|
|
33
|
+
path = self.root / META
|
|
34
|
+
tmp = path.with_suffix(".tmp")
|
|
35
|
+
tmp.write_text(json.dumps({"schema": SCHEMA, **meta}, indent=1, sort_keys=True), encoding="utf-8", newline="\n")
|
|
36
|
+
os.replace(tmp, path)
|
|
37
|
+
|
|
38
|
+
def bump_commits(self) -> int:
|
|
39
|
+
# Counted here because a shallow clone cannot see the branch's history.
|
|
40
|
+
meta = self.meta()
|
|
41
|
+
n = int(meta.get("commits") or 0) + 1
|
|
42
|
+
self.save_meta({**meta, "commits": n})
|
|
43
|
+
return n
|
|
44
|
+
|
|
45
|
+
def day_files(self) -> list[tuple[str, str, Path]]:
|
|
46
|
+
out = []
|
|
47
|
+
for stream in STREAMS:
|
|
48
|
+
folder = self.root / stream
|
|
49
|
+
if not folder.is_dir():
|
|
50
|
+
continue
|
|
51
|
+
for path in sorted(folder.iterdir()):
|
|
52
|
+
m = DAY_FILE.match(path.name)
|
|
53
|
+
if m:
|
|
54
|
+
out.append((stream, m[1], path))
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
def retention(self, today: str, days: dict[str, int] | None = None) -> dict[str, int]:
|
|
58
|
+
"""Remove day files older than each stream's retention; returns how many went, per stream."""
|
|
59
|
+
keep = {**STREAMS, **(days or {})}
|
|
60
|
+
removed = {}
|
|
61
|
+
for stream, day, path in self.day_files():
|
|
62
|
+
oldest = (date.fromisoformat(today) - timedelta(days=keep[stream])).isoformat()
|
|
63
|
+
if day < oldest:
|
|
64
|
+
path.unlink()
|
|
65
|
+
removed[stream] = removed.get(stream, 0) + 1
|
|
66
|
+
return removed
|
|
67
|
+
|
|
68
|
+
def size(self) -> int:
|
|
69
|
+
# Only the history counts, never the pages: dropping a page would not free history.
|
|
70
|
+
return sum(path.stat().st_size for _, _, path in self.day_files())
|
|
71
|
+
|
|
72
|
+
def cap(self, today: str, max_bytes: int = DEFAULT_CAP_MB * 1024 * 1024) -> list[str]:
|
|
73
|
+
"""Drop the oldest day files, whatever their stream, until the history fits; today's files always stay."""
|
|
74
|
+
dropped = []
|
|
75
|
+
files = sorted(self.day_files(), key=lambda x: (x[1], x[0]))
|
|
76
|
+
total = sum(path.stat().st_size for _, _, path in files)
|
|
77
|
+
for stream, day, path in files:
|
|
78
|
+
if total <= max_bytes or day >= today:
|
|
79
|
+
continue
|
|
80
|
+
total -= path.stat().st_size
|
|
81
|
+
path.unlink()
|
|
82
|
+
dropped.append(f"{stream}/{path.name}")
|
|
83
|
+
return dropped
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def published(result: dict) -> dict:
|
|
87
|
+
# Every file another tool reads says which schema it follows.
|
|
88
|
+
return {"schema": SCHEMA, **result}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def accept(data) -> dict | None:
|
|
92
|
+
"""A published file this version understands, or None: a newer schema is never half-read."""
|
|
93
|
+
if not isinstance(data, dict):
|
|
94
|
+
return None
|
|
95
|
+
version = data.get("schema", SCHEMA)
|
|
96
|
+
return data if isinstance(version, int) and version <= SCHEMA else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Fields that move with the clock alone: when a page differs only in these, it is not rewritten (no commit churn).
|
|
100
|
+
CLOCK_FIELDS = ("generated", "as_of", "hours", "since")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _steady(value, top: bool = True):
|
|
104
|
+
if isinstance(value, dict):
|
|
105
|
+
# "since" is the window's start at the top level, but a real date inside the records.
|
|
106
|
+
return {k: _steady(v, False) for k, v in value.items() if k not in CLOCK_FIELDS or (k == "since" and not top)}
|
|
107
|
+
if isinstance(value, list):
|
|
108
|
+
return [_steady(v, False) for v in value]
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def write_if_changed(path, data: dict, also=None, compact: bool = False) -> bool:
|
|
113
|
+
"""Write a published JSON file (and `also`'s files) unless the one there differs only in the clock fields."""
|
|
114
|
+
import json
|
|
115
|
+
try:
|
|
116
|
+
old = json.loads(path.read_text(encoding="utf-8"))
|
|
117
|
+
except (OSError, ValueError):
|
|
118
|
+
old = None
|
|
119
|
+
new = json.loads(json.dumps(data))
|
|
120
|
+
if old is not None and _steady(old) == _steady(new):
|
|
121
|
+
return False
|
|
122
|
+
path.write_text(json.dumps(data, separators=(",", ":")) if compact else json.dumps(data, indent=1), encoding="utf-8")
|
|
123
|
+
if also:
|
|
124
|
+
also()
|
|
125
|
+
return True
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tier 1 storage: small JSON-lines summaries in the data branch (`summary/`), one row per PR, job or test, that DuckDB
|
|
2
|
+
reads locally and DuckDB-WASM reads in the browser straight from GitHub, with no server and no extra install in the
|
|
3
|
+
Action (Parquet needs pyarrow, which would cost every CI-triggered run several seconds)."""
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from stackvitals.core.actors import People
|
|
9
|
+
from stackvitals.core.model import ChangeRequest
|
|
10
|
+
from stackvitals.core.store import SCHEMA
|
|
11
|
+
from stackvitals.core.time import iso
|
|
12
|
+
|
|
13
|
+
FOLDER = "summary"
|
|
14
|
+
# Fixed column types, so tables from several repos combine even when one repo's column is all empty (DuckDB would read
|
|
15
|
+
# an all-null column as JSON). A new summary field needs its type here (a test checks).
|
|
16
|
+
_RATE = {"flake_rate": "DOUBLE", "rate_low": "DOUBLE", "rate_high": "DOUBLE"}
|
|
17
|
+
TYPES = {
|
|
18
|
+
"prs": {"number": "BIGINT", "status": "VARCHAR", "draft": "BOOLEAN", "author": "VARCHAR", "author_kind": "VARCHAR",
|
|
19
|
+
"role": "VARCHAR", "created_at": "TIMESTAMP", "ready_at": "TIMESTAMP", "merged_at": "TIMESTAMP",
|
|
20
|
+
"closed_at": "TIMESTAMP", "additions": "BIGINT", "deletions": "BIGINT", "changed_files": "BIGINT",
|
|
21
|
+
"base_ref": "VARCHAR", "head_ref": "VARCHAR", "reviews": "BIGINT", "first_review_at": "TIMESTAMP",
|
|
22
|
+
"pickup_s": "DOUBLE", "cycle_s": "DOUBLE", "labels": "VARCHAR[]", "linked_issue": "BOOLEAN"},
|
|
23
|
+
"ci_jobs": {"workflow": "VARCHAR", "job": "VARCHAR", "state": "VARCHAR", "runs": "BIGINT", "failures": "BIGINT",
|
|
24
|
+
"flake_events": "BIGINT", **_RATE, "median_s": "DOUBLE", "p95_s": "DOUBLE", "last_flake": "TIMESTAMP",
|
|
25
|
+
"retry_minutes": "DOUBLE", "prs_affected": "BIGINT", "top_step": "VARCHAR", "window_days": "BIGINT"},
|
|
26
|
+
"tests": {"test_id": "VARCHAR", "suite": "VARCHAR", "workflow": "VARCHAR", "state": "VARCHAR", "runs": "BIGINT",
|
|
27
|
+
"failures": "BIGINT", "flake_events": "BIGINT", **_RATE, "last_flake": "TIMESTAMP", "message": "VARCHAR",
|
|
28
|
+
"prs_affected": "BIGINT", "window_days": "BIGINT"},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def table_types(stem: str) -> dict | None:
|
|
33
|
+
"""The column types of a summary file (ours, or DevLake's for devlake_*), or None for a file we don't know."""
|
|
34
|
+
if stem.startswith("devlake_"):
|
|
35
|
+
from stackvitals.core.export import COLUMNS
|
|
36
|
+
return COLUMNS.get(stem[len("devlake_"):])
|
|
37
|
+
return TYPES.get(stem)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _write(root: Path, name: str, rows: list[dict], now: datetime) -> str:
|
|
41
|
+
folder = root / FOLDER
|
|
42
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
path = folder / name
|
|
44
|
+
text = "".join(json.dumps(r, separators=(",", ":"), ensure_ascii=False) + "\n" for r in rows)
|
|
45
|
+
try:
|
|
46
|
+
if path.read_text(encoding="utf-8") == text:
|
|
47
|
+
return f"{FOLDER}/{name}" # same rows: no rewrite and no new time stamp, so a quiet run commits nothing
|
|
48
|
+
except OSError:
|
|
49
|
+
pass
|
|
50
|
+
path.write_text(text, encoding="utf-8", newline="\n")
|
|
51
|
+
manifest_path = folder / "manifest.json"
|
|
52
|
+
try:
|
|
53
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
54
|
+
except (OSError, ValueError):
|
|
55
|
+
manifest = {}
|
|
56
|
+
files = dict(manifest.get("files") or {})
|
|
57
|
+
files[name] = {"rows": len(rows), "generated": iso(now)}
|
|
58
|
+
manifest_path.write_text(json.dumps({"schema": SCHEMA, "generated": iso(now), "files": files}, indent=1), encoding="utf-8",
|
|
59
|
+
newline="\n")
|
|
60
|
+
return f"{FOLDER}/{name}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def pr_rows(data: dict, now: datetime, repo: str = "") -> list[dict]:
|
|
64
|
+
from stackvitals.flow.analyze import _human_reviews, stages
|
|
65
|
+
prs = (data.get("open") or []) + (data.get("closed") or [])
|
|
66
|
+
people = People(prs)
|
|
67
|
+
rows = []
|
|
68
|
+
for p in prs:
|
|
69
|
+
cr = ChangeRequest.from_flow(p, repo)
|
|
70
|
+
st = stages(p)
|
|
71
|
+
reviews = _human_reviews(p)
|
|
72
|
+
rows.append({"number": cr.number, "status": cr.state, "draft": cr.draft, "author": cr.author, "author_kind": cr.author_kind,
|
|
73
|
+
"role": people.role(cr.author), "created_at": cr.created_at, "ready_at": cr.ready_at, "merged_at": cr.merged_at,
|
|
74
|
+
"closed_at": cr.closed_at, "additions": cr.additions, "deletions": cr.deletions, "changed_files": cr.changed_files,
|
|
75
|
+
"base_ref": cr.base_ref, "head_ref": cr.head_ref, "reviews": len(reviews),
|
|
76
|
+
"first_review_at": reviews[0][1] if reviews else None, "pickup_s": st["pickup"], "cycle_s": st["cycle"],
|
|
77
|
+
"labels": cr.labels, "linked_issue": cr.linked_issue})
|
|
78
|
+
return rows
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def write_pr_summary(root: Path, data: dict, now: datetime, repo: str = "") -> list[str]:
|
|
82
|
+
return [_write(root, "prs.jsonl", pr_rows(data, now, repo), now)]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def write_ci_summary(root: Path, result: dict, now: datetime) -> list[str]:
|
|
86
|
+
window = result.get("window_days")
|
|
87
|
+
jobs = [{"workflow": j.get("workflow"), "job": j.get("job"), "state": j.get("state"), "runs": j.get("runs"),
|
|
88
|
+
"failures": j.get("failures"), "flake_events": j.get("flake_events"), "flake_rate": j.get("flake_rate"),
|
|
89
|
+
"rate_low": j.get("rate_low"), "rate_high": j.get("rate_high"), "median_s": j.get("median_s"), "p95_s": j.get("p95_s"),
|
|
90
|
+
"last_flake": j.get("last_flake"), "retry_minutes": j.get("retry_minutes"),
|
|
91
|
+
"prs_affected": len(j.get("prs_affected") or []), "top_step": j.get("top_step"), "window_days": window}
|
|
92
|
+
for j in result.get("jobs") or []]
|
|
93
|
+
tests = [{"test_id": t.get("id"), "suite": t.get("suite"), "workflow": t.get("workflow"), "state": t.get("state"),
|
|
94
|
+
"runs": t.get("runs"), "failures": t.get("failures"), "flake_events": t.get("flake_events"),
|
|
95
|
+
"flake_rate": t.get("flake_rate"), "rate_low": t.get("rate_low"), "rate_high": t.get("rate_high"),
|
|
96
|
+
"last_flake": t.get("last_flake"), "message": t.get("message"), "prs_affected": len(t.get("prs_affected") or []),
|
|
97
|
+
"window_days": window}
|
|
98
|
+
for t in result.get("tests") or []]
|
|
99
|
+
return [_write(root, "ci_jobs.jsonl", jobs, now), _write(root, "tests.jsonl", tests, now)]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def write_devlake_summary(root: Path, tables: dict[str, list[dict]], now: datetime) -> list[str]:
|
|
103
|
+
"""DevLake's domain tables beside the other summaries (`devlake_<table>.jsonl`), for DevLake's own SQL anywhere."""
|
|
104
|
+
return [_write(root, f"devlake_{name}.jsonl", rows, now) for name, rows in tables.items()]
|
stackvitals/core/time.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""GitHub timestamps ("2026-10-20T12:00:00Z") in and out, and spans that are never negative."""
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
|
|
4
|
+
FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse(text: str | None) -> datetime | None:
|
|
8
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00")) if text else None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def iso(dt: datetime) -> str:
|
|
12
|
+
return dt.strftime(FORMAT)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def secs(start: str | None, end: str | None) -> float | None:
|
|
16
|
+
# Times out of order (clock skew, imported history) give None, never a negative wait.
|
|
17
|
+
a, b = parse(start), parse(end)
|
|
18
|
+
return (b - a).total_seconds() if a and b and b >= a else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def hours(start: str | None, end: str | None) -> float | None:
|
|
22
|
+
s = secs(start, end)
|
|
23
|
+
return None if s is None else s / 3600
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def week_of(t: datetime | str) -> str:
|
|
27
|
+
# Weeks start on Monday.
|
|
28
|
+
t = parse(t) if isinstance(t, str) else t
|
|
29
|
+
return (t - timedelta(days=t.weekday())).strftime("%Y-%m-%d")
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Small, checked edits of `.github/stackvitals.toml` for settings changed in the app: a key's line is replaced where it
|
|
2
|
+
is (comments and layout stay), a missing key or table is added, None removes a key so its default applies. The result is
|
|
3
|
+
parsed again and must hold exactly the asked values; anything else (a layout this can't edit, a value the parser
|
|
4
|
+
refuses) raises EditError, so a file is never rewritten blindly."""
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import tomllib
|
|
8
|
+
from datetime import date
|
|
9
|
+
|
|
10
|
+
from stackvitals.core import config, schema
|
|
11
|
+
|
|
12
|
+
MAPS = ("dates", "map", "teams")
|
|
13
|
+
_HEADER = re.compile(r"^\s*\[\[?\s*([^\[\]]+?)\s*\]\]?\s*(#.*)?$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EditError(ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _render(value, kind: str) -> str:
|
|
21
|
+
if isinstance(value, bool):
|
|
22
|
+
return "true" if value else "false"
|
|
23
|
+
if isinstance(value, int | float):
|
|
24
|
+
return repr(value)
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
if kind == "dates":
|
|
27
|
+
return date.fromisoformat(value).isoformat()
|
|
28
|
+
return json.dumps(value, ensure_ascii=False)
|
|
29
|
+
if isinstance(value, list):
|
|
30
|
+
return "[" + ", ".join(_render(x, "list") for x in value) + "]"
|
|
31
|
+
raise EditError(f"{value!r} can't be written as a setting.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _depth(line: str) -> int:
|
|
35
|
+
# Brackets opened minus closed on a line, ignoring quoted strings and comments.
|
|
36
|
+
bare = re.sub(r'"(?:[^"\\]|\\.)*"|\'[^\']*\'', "", line).split("#", 1)[0]
|
|
37
|
+
return bare.count("[") - bare.count("]")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _tables(lines: list[str]) -> list[tuple[str, int, int]]:
|
|
41
|
+
"""(name, header line, end line) for each table, skipping lines inside multi-line values."""
|
|
42
|
+
heads, depth = [], 0
|
|
43
|
+
for i, line in enumerate(lines):
|
|
44
|
+
m = _HEADER.match(line) if depth == 0 else None
|
|
45
|
+
if m:
|
|
46
|
+
heads.append((m[1].replace(" ", ""), i))
|
|
47
|
+
continue
|
|
48
|
+
if depth or re.match(r"^\s*[\w\"'.-]+\s*=", line):
|
|
49
|
+
depth = max(depth + _depth(line), 0)
|
|
50
|
+
return [(name, start, heads[k + 1][1] if k + 1 < len(heads) else len(lines)) for k, (name, start) in enumerate(heads)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _comment(line: str) -> str:
|
|
54
|
+
# The line's trailing comment (outside quoted strings), kept when its value is replaced.
|
|
55
|
+
bare = re.sub(r'"(?:[^"\\]|\\.)*"|\'[^\']*\'', lambda m: " " * len(m[0]), line)
|
|
56
|
+
at = bare.find("#")
|
|
57
|
+
return line[at:].rstrip() if at >= 0 else ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _key_span(lines: list[str], start: int, end: int, leaf: str) -> tuple[int, int] | None:
|
|
61
|
+
pattern = re.compile(rf"^\s*(?:{re.escape(leaf)}|\"{re.escape(leaf)}\"|'{re.escape(leaf)}')\s*=")
|
|
62
|
+
for i in range(start, end):
|
|
63
|
+
if pattern.match(lines[i]):
|
|
64
|
+
j, depth = i, _depth(lines[i])
|
|
65
|
+
while depth > 0 and j + 1 < end:
|
|
66
|
+
j += 1
|
|
67
|
+
depth += _depth(lines[j])
|
|
68
|
+
return i, j + 1
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _set(lines: list[str], table: str, body: list[str] | None, leaf: str | None) -> list[str]:
|
|
73
|
+
"""Put `body` (the new lines) for `leaf` in `table` (leaf None: the whole table's body); body None removes it."""
|
|
74
|
+
found = next(((s, e) for name, s, e in _tables(lines) if name == table), None)
|
|
75
|
+
if found is None:
|
|
76
|
+
if body is None:
|
|
77
|
+
return lines
|
|
78
|
+
gap = [""] if lines and lines[-1].strip() else []
|
|
79
|
+
return lines + gap + [f"[{table}]", *body]
|
|
80
|
+
start, end = found
|
|
81
|
+
if leaf is None:
|
|
82
|
+
tail = end
|
|
83
|
+
while tail > start + 1 and not lines[tail - 1].strip():
|
|
84
|
+
tail -= 1
|
|
85
|
+
return lines[:start + 1] + (body or []) + lines[tail:end] + lines[end:]
|
|
86
|
+
span = _key_span(lines, start + 1, end, leaf)
|
|
87
|
+
if span:
|
|
88
|
+
note = _comment(lines[span[0]]) if body and len(body) == 1 and span[1] - span[0] == 1 else ""
|
|
89
|
+
return lines[:span[0]] + ([f"{body[0]} {note}"] if note else body or []) + lines[span[1]:]
|
|
90
|
+
if body is None:
|
|
91
|
+
return lines
|
|
92
|
+
last = end
|
|
93
|
+
while last > start + 1 and not lines[last - 1].strip():
|
|
94
|
+
last -= 1
|
|
95
|
+
return lines[:last] + body + lines[last:]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _expected(setting: schema.Setting, value):
|
|
99
|
+
if value is None:
|
|
100
|
+
return schema.default(setting.key)
|
|
101
|
+
if setting.kind == "int":
|
|
102
|
+
return int(value)
|
|
103
|
+
if setting.kind in ("number", "share", "hours"):
|
|
104
|
+
return float(value)
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _get(cfg: dict, key: str):
|
|
109
|
+
node = cfg
|
|
110
|
+
for part in key.split("."):
|
|
111
|
+
node = node[part]
|
|
112
|
+
return node
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def apply(text: str | None, changes: dict) -> str:
|
|
116
|
+
lines = (text or "").replace("\r\n", "\n").split("\n")
|
|
117
|
+
if lines and lines[-1] == "":
|
|
118
|
+
lines = lines[:-1]
|
|
119
|
+
for key, value in changes.items():
|
|
120
|
+
setting = schema.BY_KEY.get(key)
|
|
121
|
+
if setting is None:
|
|
122
|
+
raise EditError(f"{key} is not a setting StackVitals knows.")
|
|
123
|
+
try:
|
|
124
|
+
if setting.kind in MAPS:
|
|
125
|
+
if value is not None and not isinstance(value, dict):
|
|
126
|
+
raise EditError(f"{key} takes a table of entries.")
|
|
127
|
+
body = None if value is None else [f"{json.dumps(str(k), ensure_ascii=False)} = {_render(v, setting.kind)}"
|
|
128
|
+
for k, v in value.items()]
|
|
129
|
+
lines = _set(lines, key, body, None)
|
|
130
|
+
else:
|
|
131
|
+
table, leaf = key.rsplit(".", 1)
|
|
132
|
+
lines = _set(lines, table, None if value is None else [f"{leaf} = {_render(value, setting.kind)}"], leaf)
|
|
133
|
+
except ValueError as exc:
|
|
134
|
+
raise EditError(f"{key}: {exc}") from None
|
|
135
|
+
out = "\n".join(lines).strip("\n") + "\n"
|
|
136
|
+
try:
|
|
137
|
+
tomllib.loads(out)
|
|
138
|
+
except tomllib.TOMLDecodeError as exc:
|
|
139
|
+
raise EditError(f"this file's layout can't be edited automatically ({exc}); change it by hand.") from None
|
|
140
|
+
cfg, notes = config.parse(out)
|
|
141
|
+
for key, value in changes.items():
|
|
142
|
+
try:
|
|
143
|
+
want = _expected(schema.BY_KEY[key], value)
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
raise EditError(f"{key} can't be set to {value!r}: it needs a {schema.BY_KEY[key].kind}.") from None
|
|
146
|
+
if _get(cfg, key) != want:
|
|
147
|
+
why = next((n for n in notes if key.rsplit(".", 1)[-1] in n), "the file's layout kept the old value")
|
|
148
|
+
raise EditError(f"{key} can't be set to {value!r}: {why}")
|
|
149
|
+
return out
|