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,224 @@
|
|
|
1
|
+
"""Alerts on delivery numbers over the last 28 days: a heads-up at 80% of a threshold and a miss past it, each sent once
|
|
2
|
+
per change of state (and once on recovery), "would have fired N times" replayed over stored history, and payloads for
|
|
3
|
+
Slack, Discord, Microsoft Teams (Workflows) and plain JSON webhooks."""
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from stackvitals.core import chat
|
|
9
|
+
from stackvitals.core.stats import percentile, quantile_range, wilson
|
|
10
|
+
from stackvitals.core.time import iso
|
|
11
|
+
|
|
12
|
+
WINDOW_DAYS = 28
|
|
13
|
+
HEADS_UP = 0.8
|
|
14
|
+
MIN_N = 10
|
|
15
|
+
LEVELS = ("ok", "heads_up", "miss")
|
|
16
|
+
NAMES = {"pickup_p75_hours": "pickup time P75 (hours)", "review_p75_hours": "review time P75 (hours)",
|
|
17
|
+
"cycle_p75_hours": "cycle time to merge P75 (hours)", "change_failure_rate": "change failure rate"}
|
|
18
|
+
PHASE_OF = {"pickup_p75_hours": "pickup", "review_p75_hours": "review", "cycle_p75_hours": "cycle_to_merge"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def thresholds(cfg: dict) -> dict:
|
|
22
|
+
"""The thresholds in force: every metric set above 0."""
|
|
23
|
+
return {k: v for k, v in cfg["alerts"].items() if k in NAMES and v}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def series(rows: list[tuple], deps: list[dict], incidents: list[dict], now: datetime, days: int = 90,
|
|
27
|
+
window: int = WINDOW_DAYS) -> dict[str, list[tuple]]:
|
|
28
|
+
"""Each metric for each of the last `days` days, over the `window` days before it: [(day, value, n, low)], oldest
|
|
29
|
+
first; `low` is the bottom of the value's likely range, so a miss can wait until the whole range is past the limit."""
|
|
30
|
+
out = {k: [] for k in NAMES}
|
|
31
|
+
failed = {i["deployment"] for i in incidents if i.get("deployment")}
|
|
32
|
+
for k in range(days, -1, -1):
|
|
33
|
+
end = now - timedelta(days=k)
|
|
34
|
+
lo, hi = iso(end - timedelta(days=window)), iso(end)
|
|
35
|
+
merged = [ph for p, ph, _dep in rows if p.get("mg") and lo < p["mg"] <= hi]
|
|
36
|
+
for metric, phase in PHASE_OF.items():
|
|
37
|
+
values = [ph[phase] for ph in merged if ph.get(phase) is not None]
|
|
38
|
+
rng = quantile_range(values, 0.75)
|
|
39
|
+
out[metric].append((end.date().isoformat(), percentile(values, 0.75) if values else None, len(values),
|
|
40
|
+
rng[0] if rng else None))
|
|
41
|
+
shipped = [d for d in deps if lo < d["finished"] <= hi]
|
|
42
|
+
bad = sum(1 for d in shipped if d["id"] in failed)
|
|
43
|
+
rate = bad / len(shipped) if shipped else None
|
|
44
|
+
out["change_failure_rate"].append((end.date().isoformat(), rate, len(shipped),
|
|
45
|
+
round(wilson(bad, len(shipped))[0], 4) if shipped else None))
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _level(value: float | None, n: int, limit: float, low: float | None = None) -> str:
|
|
50
|
+
if value is None or n < MIN_N:
|
|
51
|
+
return "not enough data"
|
|
52
|
+
if value > limit:
|
|
53
|
+
# Over, but the likely range still reaches under the limit: a heads-up until the evidence settles it.
|
|
54
|
+
return "miss" if low is None or low > limit else "heads_up"
|
|
55
|
+
return "heads_up" if value >= HEADS_UP * limit else "ok"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def evaluate(values: dict[str, tuple], limits: dict, previous: dict) -> tuple[dict, list[dict]]:
|
|
59
|
+
"""(new state, events) from today's (value, n) per metric: an event when a level rises, and "recovered" when a
|
|
60
|
+
metric that missed is back to ok."""
|
|
61
|
+
state, events = {}, []
|
|
62
|
+
for metric, limit in limits.items():
|
|
63
|
+
value, n, *rest = values.get(metric, (None, 0))
|
|
64
|
+
before = previous.get(metric) or {}
|
|
65
|
+
last = before.get("verdict") or "ok" # the last real verdict: a quiet week without data changes nothing
|
|
66
|
+
level = _level(value, n, limit, rest[0] if rest else None)
|
|
67
|
+
# An open miss stays a miss while the value stays over the limit, however its range moves.
|
|
68
|
+
if level == "heads_up" and before.get("open") and value is not None and value > limit:
|
|
69
|
+
level = "miss"
|
|
70
|
+
now = {"level": level, "verdict": last if level == "not enough data" else level, "open": before.get("open", False),
|
|
71
|
+
"value": value, "n": n}
|
|
72
|
+
if level in LEVELS[1:] and LEVELS.index(level) > LEVELS.index(last):
|
|
73
|
+
events.append({"metric": metric, "level": level, "value": value, "limit": limit, "n": n})
|
|
74
|
+
now["open"] = now["open"] or level == "miss"
|
|
75
|
+
elif level == "ok" and before.get("open"):
|
|
76
|
+
events.append({"metric": metric, "level": "recovered", "value": value, "limit": limit, "n": n})
|
|
77
|
+
now["open"] = False
|
|
78
|
+
state[metric] = now
|
|
79
|
+
return state, events
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def replay(history: dict[str, list[tuple]], limits: dict) -> dict[str, int]:
|
|
83
|
+
"""How many alerts each threshold would have sent over the stored days."""
|
|
84
|
+
fired = {m: 0 for m in limits}
|
|
85
|
+
state = {}
|
|
86
|
+
for k in range(max((len(v) for v in history.values()), default=0)):
|
|
87
|
+
today = {m: tuple(history[m][k][1:]) for m in limits if m in history and k < len(history[m])}
|
|
88
|
+
state, events = evaluate(today, limits, state)
|
|
89
|
+
for e in events:
|
|
90
|
+
fired[e["metric"]] += 1
|
|
91
|
+
return fired
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fmt(metric: str, value) -> str:
|
|
95
|
+
return f"{value:.0%}" if metric == "change_failure_rate" else f"{value:.1f}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def message(events: list[dict], repo: str, links=()) -> dict:
|
|
99
|
+
words = {"heads_up": "heads-up: near its limit", "miss": "over its limit", "recovered": "back under its limit"}
|
|
100
|
+
unsure = "heads-up: past its limit, but its likely range still reaches under it"
|
|
101
|
+
lines = [f"- **{NAMES[e['metric']]}** {unsure if e['level'] == 'heads_up' and e['value'] > e['limit'] else words[e['level']]}: "
|
|
102
|
+
f"{_fmt(e['metric'], e['value'])} against {_fmt(e['metric'], e['limit'])} (n={e['n']})." for e in events]
|
|
103
|
+
return chat.message(f"StackVitals alerts for {repo} (last {WINDOW_DAYS} days)", lines, links,
|
|
104
|
+
{"repo": repo, "alerts": events})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def send(url: str, events: list[dict], repo: str, client: httpx.Client | None = None, links=()) -> str | None:
|
|
108
|
+
"""Post the alerts in the chat's own shape; trouble comes back as text for the job summary."""
|
|
109
|
+
return chat.post(url, message(events, repo, links), client, "the alert webhook")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
STATE = "alerts/state.json"
|
|
113
|
+
ISSUE_TITLE = "StackVitals: delivery alerts"
|
|
114
|
+
ISSUE_LABEL = "stackvitals"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _issue_body(state: dict, limits: dict) -> str:
|
|
118
|
+
words = {"ok": "ok", "heads_up": "near its limit", "miss": "over its limit", "not enough data": "not enough data"}
|
|
119
|
+
lines = [f"Delivery numbers over the last {WINDOW_DAYS} days against this repo's thresholds:", ""]
|
|
120
|
+
for metric, limit in limits.items():
|
|
121
|
+
s = state[metric]
|
|
122
|
+
value = "-" if s.get("value") is None else _fmt(metric, s["value"])
|
|
123
|
+
lines.append(f"- {NAMES[metric]}: {value} against {_fmt(metric, limit)} (n={s.get('n', 0)}): {words[s['level']]}")
|
|
124
|
+
lines += ["", "Change the thresholds under [alerts] in .github/stackvitals.toml (0 turns one off). This issue is kept "
|
|
125
|
+
"up to date and closed when every number is back under its limit."]
|
|
126
|
+
return "\n".join(lines)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _sync_issue(api, state: dict, limits: dict) -> None:
|
|
130
|
+
found = [i for i in api._get(f"/repos/{api.repo}/issues", {"labels": ISSUE_LABEL, "state": "open", "per_page": 100}) or []
|
|
131
|
+
if i.get("title") == ISSUE_TITLE and "pull_request" not in i]
|
|
132
|
+
bad = any(state[m]["verdict"] in ("heads_up", "miss") for m in limits)
|
|
133
|
+
body = _issue_body(state, limits)
|
|
134
|
+
if found and not bad:
|
|
135
|
+
api.send("PATCH", f"/repos/{api.repo}/issues/{found[0]['number']}", {"body": body, "state": "closed",
|
|
136
|
+
"state_reason": "completed"})
|
|
137
|
+
elif found:
|
|
138
|
+
api.send("PATCH", f"/repos/{api.repo}/issues/{found[0]['number']}", {"body": body})
|
|
139
|
+
elif bad:
|
|
140
|
+
api.send("POST", f"/repos/{api.repo}/issues", {"title": ISSUE_TITLE, "body": body, "labels": [ISSUE_LABEL]})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def alerts_step(ws, env: dict, now: datetime, make_api=None, client: httpx.Client | None = None) -> dict | None:
|
|
144
|
+
"""The Action's alerts after Team Flow (the schedule): today's numbers against the thresholds, sent once per change of
|
|
145
|
+
state; the stored state keeps levels only, so a run where nothing crossed a line commits nothing."""
|
|
146
|
+
import json
|
|
147
|
+
|
|
148
|
+
from stackvitals.ci.store import DataStore
|
|
149
|
+
from stackvitals.core.config import parse
|
|
150
|
+
from stackvitals.core.delivery import apply_identity, shipped
|
|
151
|
+
from stackvitals.flow.store import FlowStore
|
|
152
|
+
if ws.results.get("Team Flow") is None:
|
|
153
|
+
return None
|
|
154
|
+
cfg = ws.cache.get("settings") or parse("")[0]
|
|
155
|
+
limits = thresholds(cfg)
|
|
156
|
+
if not limits:
|
|
157
|
+
return None
|
|
158
|
+
store = FlowStore(ws.path)
|
|
159
|
+
from stackvitals.core.decisions import visible
|
|
160
|
+
flow = visible(ws.path, apply_identity(ws.cache.get("flow") or store.load(), cfg.get("identity")), now.date())
|
|
161
|
+
ci = ws.cache.get("ci") or (DataStore(ws.path).load() if (ws.path / "ci" / "days").is_dir() else {"runs": [], "jobs": []})
|
|
162
|
+
issues = []
|
|
163
|
+
if (ws.path / "community" / "issues").is_dir():
|
|
164
|
+
got = FlowStore(ws.path, "community/issues").load()
|
|
165
|
+
issues = got["open"] + got["closed"]
|
|
166
|
+
info = store.repo()
|
|
167
|
+
deps, _source, rows = shipped(flow, info, ci, cfg)
|
|
168
|
+
from stackvitals.connectors import active, public
|
|
169
|
+
from stackvitals.connectors import load as load_tracker
|
|
170
|
+
from stackvitals.core.delivery import incidents_for
|
|
171
|
+
tracker = ws.cache.get("tracker") or load_tracker(ws.path, redact=public(ws), names=active(env, cfg))
|
|
172
|
+
incidents = incidents_for(flow["closed"] + flow["open"], issues, deps, cfg, tracker, info.get("recorded"))[0]
|
|
173
|
+
history = series(rows, deps, incidents, now)
|
|
174
|
+
# Whole days only for the app's "would have fired" preview, so the published file changes at most once a day.
|
|
175
|
+
from stackvitals.core.store import published, write_if_changed
|
|
176
|
+
daily = series(rows, deps, incidents, now.replace(hour=0, minute=0, second=0, microsecond=0))
|
|
177
|
+
(ws.path / "docs").mkdir(parents=True, exist_ok=True)
|
|
178
|
+
write_if_changed(ws.path / "docs" / "alerts.json", published({
|
|
179
|
+
"window_days": WINDOW_DAYS, "heads_up": HEADS_UP, "min_n": MIN_N, "limits": limits, "names": NAMES,
|
|
180
|
+
"series": {m: [[d, None if v is None else round(v, 3), n, None if low is None else round(low, 3)]
|
|
181
|
+
for d, v, n, low in points] for m, points in daily.items()}}))
|
|
182
|
+
today = {m: tuple(history[m][-1][1:]) for m in limits}
|
|
183
|
+
path = ws.path / STATE
|
|
184
|
+
try:
|
|
185
|
+
old = json.loads(path.read_text(encoding="utf-8"))
|
|
186
|
+
except (OSError, ValueError):
|
|
187
|
+
old = {}
|
|
188
|
+
state, events = evaluate(today, limits, old.get("metrics") or {})
|
|
189
|
+
fired = replay(history, limits) if old.get("limits") != limits else None
|
|
190
|
+
notes, channels = [], cfg["alerts"]["channels"]
|
|
191
|
+
if events and "webhook" in channels and env.get("STACKVITALS_WEBHOOK"):
|
|
192
|
+
app = chat.app_url(ws.repo, ws.cache.get("repo_info"), "delivery")
|
|
193
|
+
links = [("Open Delivery", app)] if app else []
|
|
194
|
+
links.append(("Alerts issue", f"https://github.com/{ws.repo}/issues?q=is%3Aissue+label%3Astackvitals+alerts"))
|
|
195
|
+
failed = send(env["STACKVITALS_WEBHOOK"], events, ws.repo, client=client, links=links)
|
|
196
|
+
if failed:
|
|
197
|
+
notes.append(failed)
|
|
198
|
+
if (events or fired is not None) and "issue" in channels:
|
|
199
|
+
if make_api is None:
|
|
200
|
+
from stackvitals.ci.api import ActionsAPI as make_api
|
|
201
|
+
try:
|
|
202
|
+
_sync_issue(make_api(env.get("STACKVITALS_TOKEN"), ws.repo, max_requests=10), state, limits)
|
|
203
|
+
except Exception as exc: # noqa: BLE001 - a missing permission must not stop the job
|
|
204
|
+
notes.append(f"Could not update the alerts issue ({type(exc).__name__}); it needs `issues: write`.")
|
|
205
|
+
keep = {"limits": limits, "metrics": {m: {"verdict": s["verdict"], "open": s["open"]} for m, s in state.items()}}
|
|
206
|
+
if keep != old:
|
|
207
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
path.write_text(json.dumps(keep, indent=1, sort_keys=True), encoding="utf-8")
|
|
209
|
+
|
|
210
|
+
def after_commit():
|
|
211
|
+
summary = env.get("GITHUB_STEP_SUMMARY")
|
|
212
|
+
if not summary or not (events or fired is not None or notes):
|
|
213
|
+
return
|
|
214
|
+
lines = []
|
|
215
|
+
if events:
|
|
216
|
+
lines.append(chat.text(message(events, ws.repo)))
|
|
217
|
+
if fired is not None:
|
|
218
|
+
lines.append("With these thresholds, alerts would have fired " + ", ".join(
|
|
219
|
+
f"{n} times for {NAMES[m]}" for m, n in fired.items()) + f" over the last 90 days (n of each day's {WINDOW_DAYS}-day window shown with every alert).")
|
|
220
|
+
lines += notes
|
|
221
|
+
with open(summary, "a", encoding="utf-8") as f:
|
|
222
|
+
f.write("### StackVitals alerts\n" + "\n\n".join(lines) + "\n")
|
|
223
|
+
ws.after_commit(after_commit, "Alerts")
|
|
224
|
+
return {"events": len(events), "levels": {m: s["level"] for m, s in state.items()}, "would_have_fired": fired}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The files the StackVitals app reads, written beside the pages at the end of every job: app.json (what ran and when,
|
|
2
|
+
links, the key facts), settings.json (every setting with its default and current value), decisions.json (what people
|
|
3
|
+
decided, with the log) and, for public repos, the summary tables, so the app and Explore read everything from Pages
|
|
4
|
+
itself rather than raw.githubusercontent.com, which GitHub throttles. Files are rewritten only when they change."""
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from stackvitals.core import schema
|
|
10
|
+
from stackvitals.core.config import FILE, parse
|
|
11
|
+
from stackvitals.core.decisions import UNDO, Store
|
|
12
|
+
from stackvitals.core.store import published, write_if_changed
|
|
13
|
+
|
|
14
|
+
# Module, its published file and its page; Portal keeps its catalog outside docs/ (copied in for the app).
|
|
15
|
+
MODULES = (("ci", "data.json", "ci.html"), ("flow", "flow.json", "flow.html"), ("delivery", "delivery.json", "delivery.html"),
|
|
16
|
+
("insights", "insights.json", "insights.html"), ("community", "community.json", "community.html"),
|
|
17
|
+
("portal", "portal.json", "portal.html"), ("alerts", "alerts.json", None), ("deps", "deps.json", None),
|
|
18
|
+
("supply", "supply.json", None), ("vulns", "vulns.json", None), ("security", "security.json", None))
|
|
19
|
+
LOG_ROWS = 200
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read(path: Path):
|
|
23
|
+
try:
|
|
24
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
25
|
+
except (OSError, ValueError):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def facts(docs: dict) -> dict:
|
|
30
|
+
"""The few numbers a home page, the digest or an org view lead with; None where a module didn't run."""
|
|
31
|
+
data, flow, delivery = docs.get("ci") or {}, docs.get("flow") or {}, docs.get("delivery") or {}
|
|
32
|
+
items = (data.get("tests") or []) + (data.get("jobs") or [])
|
|
33
|
+
phase = lambda name: ((delivery.get("phases") or {}).get(name) or {}).get("p75_h")
|
|
34
|
+
found = (docs.get("insights") or {}).get("findings") or []
|
|
35
|
+
return {
|
|
36
|
+
"ci_first_try": (data.get("pushes") or {}).get("first_try_share") if data else None,
|
|
37
|
+
"ci_flaky": sum(1 for i in items if i.get("state") == "flaky") if data else None,
|
|
38
|
+
"ci_broken": sum(1 for i in items if i.get("state") == "broken") if data else None,
|
|
39
|
+
"open_prs": flow.get("open"), "stale_prs": flow.get("stale"),
|
|
40
|
+
"pickup_p75_h": phase("pickup"), "review_p75_h": phase("review"), "cycle_p75_h": phase("cycle_to_merge"),
|
|
41
|
+
"deployments": (delivery.get("dora") or {}).get("deployments"),
|
|
42
|
+
"change_failure_rate": (delivery.get("dora") or {}).get("change_failure_rate"),
|
|
43
|
+
"newcomers_waiting": (docs.get("community") or {}).get("waiting_total"),
|
|
44
|
+
"findings": sum(1 for f in found if not f.get("decision")) if docs.get("insights") else None,
|
|
45
|
+
"first_review_median_s": ((flow.get("stages") or {}).get("pickup") or {}).get("median_s"),
|
|
46
|
+
"community_score": ((docs.get("community") or {}).get("score") or {}).get("score"),
|
|
47
|
+
"vulns_open": len((docs.get("vulns") or {}).get("open") or []) if docs.get("vulns") else None,
|
|
48
|
+
"vulns_serious": sum(1 for v in (docs.get("vulns") or {}).get("open") or []
|
|
49
|
+
if v.get("malware") or v.get("severity") in ("critical", "high")) if docs.get("vulns") else None,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def write_app_data(ws) -> None:
|
|
54
|
+
root, now = ws.path, ws.now
|
|
55
|
+
docs_dir = root / "docs"
|
|
56
|
+
if not docs_dir.is_dir():
|
|
57
|
+
return
|
|
58
|
+
info = ws.cache.get("repo_info") or {}
|
|
59
|
+
last = _read(docs_dir / "app.json") or {}
|
|
60
|
+
branch = info.get("default_branch") or last.get("default_branch") or "main"
|
|
61
|
+
# Without the repo's details, the last known visibility holds; never known means private (publish nothing).
|
|
62
|
+
private = bool(info["private"]) if "private" in info else last.get("visibility") != "public"
|
|
63
|
+
if private and info.get("has_pages") and (ws.env.get("STACKVITALS_PAGES_MODE") or "branch") != "artifact":
|
|
64
|
+
# Pages from a private repo are public on most plans: say so on every run until it's changed.
|
|
65
|
+
ws.notes.append("This repo is private but has GitHub Pages turned on; unless your plan keeps Pages private, anyone "
|
|
66
|
+
"with the address can read the app. Turn Pages off and set pages: artifact, or use stackvitals ui.")
|
|
67
|
+
catalog = _read(root / "portal" / "catalog.json")
|
|
68
|
+
if catalog is not None:
|
|
69
|
+
# The app's copy is packed (each rule named once) and compact: 10,000 repos would be 24 MB as written.
|
|
70
|
+
from stackvitals.portal.scorecard import pack
|
|
71
|
+
write_if_changed(docs_dir / "portal.json", published(pack(catalog)), compact=True)
|
|
72
|
+
docs = {name: _read(docs_dir / file) for name, file, _page in MODULES}
|
|
73
|
+
workflow = (ws.env.get("GITHUB_WORKFLOW_REF") or "").split("@")[0].rsplit("/", 1)[-1] or None
|
|
74
|
+
base = f"https://github.com/{ws.repo}"
|
|
75
|
+
state, log = Store(root).state(now.date()), Store(root).log()
|
|
76
|
+
write_if_changed(docs_dir / "app.json", published({
|
|
77
|
+
"repo": ws.repo, "default_branch": branch, "visibility": "private" if private else "public",
|
|
78
|
+
"modules": {name: {"updated": (docs[name] or {}).get("generated") or (docs[name] or {}).get("built_at"), "file": file,
|
|
79
|
+
**({"page": page} if page and (docs_dir / page).exists() else {})}
|
|
80
|
+
for name, file, page in MODULES if docs[name] is not None},
|
|
81
|
+
"pages": sorted(p.name for p in docs_dir.glob("*.html")),
|
|
82
|
+
"facts": {**facts(docs), "decisions": sum(1 for v in state.values() if v.get("kind") != "setting" and v.get("verb") not in UNDO)},
|
|
83
|
+
"links": {"repo": base, "workflow": workflow,
|
|
84
|
+
"actions": f"{base}/actions/workflows/{workflow}" if workflow else f"{base}/actions",
|
|
85
|
+
"settings_file": f"{base}/blob/{branch}/{FILE}", "edit_settings": f"{base}/edit/{branch}/{FILE}",
|
|
86
|
+
"new_issue": f"{base}/issues/new"},
|
|
87
|
+
"summary": "summary/" if not private else None,
|
|
88
|
+
}))
|
|
89
|
+
cfg = ws.cache.get("settings") or parse("")[0]
|
|
90
|
+
write_if_changed(docs_dir / "settings.json", published({
|
|
91
|
+
"file": FILE, "settings": schema.to_json(), "values": cfg, "notes": list(ws.cache.get("settings_notes") or [])}))
|
|
92
|
+
write_if_changed(docs_dir / "decisions.json", published({"state": state, "log": list(reversed(log))[:LOG_ROWS]}))
|
|
93
|
+
_tables(root, private)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _tables(root: Path, private: bool) -> None:
|
|
97
|
+
"""Public repos: the summary tables next to the pages (git keeps one copy of identical files). Private: kept out."""
|
|
98
|
+
src, dst = root / "summary", root / "docs" / "summary"
|
|
99
|
+
if private or not src.is_dir():
|
|
100
|
+
if dst.exists():
|
|
101
|
+
shutil.rmtree(dst)
|
|
102
|
+
return
|
|
103
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
names = {p.name for p in src.iterdir() if p.is_file()}
|
|
105
|
+
for p in dst.iterdir():
|
|
106
|
+
if p.is_file() and p.name not in names:
|
|
107
|
+
p.unlink()
|
|
108
|
+
for name in names:
|
|
109
|
+
data = (src / name).read_bytes()
|
|
110
|
+
target = dst / name
|
|
111
|
+
if not target.exists() or target.read_bytes() != data:
|
|
112
|
+
target.write_bytes(data)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Optional storage outside git: the same files the stackvitals-data branch holds, kept in an S3-compatible bucket (AWS S3,
|
|
2
|
+
Cloudflare R2, MinIO and others that support conditional writes). For very busy repos and big orgs, where many runs
|
|
3
|
+
writing one branch collide. Files are stored by their content hash, and one manifest maps paths to hashes; the manifest
|
|
4
|
+
is replaced only if nobody replaced it since this run read it (If-Match), so overlapping runs converge as with git.
|
|
5
|
+
Requests are signed with AWS Signature V4; no SDK is needed."""
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import quote, urlparse
|
|
15
|
+
|
|
16
|
+
MANIFEST = "manifest.json"
|
|
17
|
+
EMPTY = hashlib.sha256(b"").hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BucketError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sha(data: bytes) -> str:
|
|
25
|
+
return hashlib.sha256(data).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sign(method: str, url: str, headers: dict, payload_sha: str, key_id: str, secret: str, region: str,
|
|
29
|
+
now: datetime, service: str = "s3") -> dict:
|
|
30
|
+
"""The headers of a request signed with AWS Signature Version 4 (path-style URL)."""
|
|
31
|
+
u = urlparse(url)
|
|
32
|
+
stamp, day = now.strftime("%Y%m%dT%H%M%SZ"), now.strftime("%Y%m%d")
|
|
33
|
+
all_headers = {**{k.lower(): str(v).strip() for k, v in headers.items()}, "host": u.netloc,
|
|
34
|
+
"x-amz-date": stamp, "x-amz-content-sha256": payload_sha}
|
|
35
|
+
names = sorted(all_headers)
|
|
36
|
+
query = "&".join(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}" for k, v in
|
|
37
|
+
sorted(tuple(p.split("=", 1)) if "=" in p else (p, "") for p in u.query.split("&") if p))
|
|
38
|
+
# The URL's path is already percent-encoded (BucketStore._url); S3 signs it as sent, never encoded twice.
|
|
39
|
+
canonical = "\n".join([method, u.path or "/", query,
|
|
40
|
+
"".join(f"{n}:{all_headers[n]}\n" for n in names), ";".join(names), payload_sha])
|
|
41
|
+
scope = f"{day}/{region}/{service}/aws4_request"
|
|
42
|
+
to_sign = "\n".join(["AWS4-HMAC-SHA256", stamp, scope, _sha(canonical.encode())])
|
|
43
|
+
key = f"AWS4{secret}".encode()
|
|
44
|
+
for part in (day, region, service, "aws4_request"):
|
|
45
|
+
key = hmac.new(key, part.encode(), hashlib.sha256).digest()
|
|
46
|
+
signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
|
|
47
|
+
return {**{k: v for k, v in all_headers.items() if k != "host"},
|
|
48
|
+
"authorization": f"AWS4-HMAC-SHA256 Credential={key_id}/{scope}, SignedHeaders={';'.join(names)}, Signature={signature}"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BucketStore:
|
|
52
|
+
"""The data branch's interface (open, dirty, commit_push, squash) over a bucket prefix."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, url: str, path: Path, endpoint: str, key_id: str, secret: str, region: str = "auto", client=None):
|
|
55
|
+
u = urlparse(url)
|
|
56
|
+
if u.scheme != "s3" or not u.netloc:
|
|
57
|
+
raise BucketError("storage must look like s3://bucket/prefix")
|
|
58
|
+
self.bucket, self.prefix = u.netloc, u.path.strip("/")
|
|
59
|
+
self.endpoint = endpoint.rstrip("/")
|
|
60
|
+
self.key_id, self.secret, self.region = key_id, secret, region or "auto"
|
|
61
|
+
self.path = Path(path)
|
|
62
|
+
self.client = client
|
|
63
|
+
self.etag = None
|
|
64
|
+
self.files: dict[str, str] = {}
|
|
65
|
+
|
|
66
|
+
# -- requests ------------------------------------------------------------------------------------------------
|
|
67
|
+
def _url(self, key: str) -> str:
|
|
68
|
+
return f"{self.endpoint}/{self.bucket}/{quote(key, safe='/-_.~')}"
|
|
69
|
+
|
|
70
|
+
def _key(self, name: str) -> str:
|
|
71
|
+
return f"{self.prefix}/{name}" if self.prefix else name
|
|
72
|
+
|
|
73
|
+
def _send(self, method: str, key: str, body: bytes = b"", headers: dict | None = None):
|
|
74
|
+
if self.client is None:
|
|
75
|
+
import httpx
|
|
76
|
+
self.client = httpx.Client(timeout=60)
|
|
77
|
+
url = self._url(key)
|
|
78
|
+
signed = sign(method, url, headers or {}, _sha(body) if body else EMPTY, self.key_id, self.secret, self.region,
|
|
79
|
+
datetime.now(timezone.utc))
|
|
80
|
+
for attempt in range(3):
|
|
81
|
+
try:
|
|
82
|
+
r = self.client.request(method, url, content=body or None, headers=signed)
|
|
83
|
+
except Exception as exc: # noqa: BLE001 - a dropped connection is retried, then said
|
|
84
|
+
if attempt == 2:
|
|
85
|
+
raise BucketError(f"{method} {key}: {type(exc).__name__}") from exc
|
|
86
|
+
time.sleep(1 + attempt)
|
|
87
|
+
continue
|
|
88
|
+
if r.status_code >= 500 and attempt < 2:
|
|
89
|
+
time.sleep(1 + attempt)
|
|
90
|
+
continue
|
|
91
|
+
return r
|
|
92
|
+
return r
|
|
93
|
+
|
|
94
|
+
# -- the data branch's interface ------------------------------------------------------------------------------
|
|
95
|
+
def open(self) -> None:
|
|
96
|
+
"""Download every file the manifest lists into `path`, remembering the manifest's ETag."""
|
|
97
|
+
import shutil
|
|
98
|
+
for attempt in range(3):
|
|
99
|
+
if self.path.exists():
|
|
100
|
+
shutil.rmtree(self.path)
|
|
101
|
+
self.path.mkdir(parents=True)
|
|
102
|
+
r = self._send("GET", self._key(MANIFEST))
|
|
103
|
+
if r.status_code == 404:
|
|
104
|
+
self.etag, self.files = None, {}
|
|
105
|
+
return
|
|
106
|
+
if r.status_code == 403:
|
|
107
|
+
raise BucketError("reading the manifest: HTTP 403. The key must be able to read, write, delete and list "
|
|
108
|
+
"the prefix; AWS answers a missing file with 403 when listing isn't allowed")
|
|
109
|
+
if r.status_code != 200:
|
|
110
|
+
raise BucketError(f"reading the manifest: HTTP {r.status_code}")
|
|
111
|
+
self.etag = r.headers.get("etag")
|
|
112
|
+
self.files = json.loads(r.content).get("files") or {}
|
|
113
|
+
gone = False
|
|
114
|
+
for name, digest in self.files.items():
|
|
115
|
+
obj = self._send("GET", self._key(f"objects/{digest}"))
|
|
116
|
+
if obj.status_code == 404:
|
|
117
|
+
gone = True # another run swapped the manifest and cleaned up while this one read: start over
|
|
118
|
+
break
|
|
119
|
+
if obj.status_code != 200 or _sha(obj.content) != digest:
|
|
120
|
+
raise BucketError(f"reading {name}: HTTP {obj.status_code}")
|
|
121
|
+
target = self.path / name
|
|
122
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
target.write_bytes(obj.content)
|
|
124
|
+
if not gone:
|
|
125
|
+
return
|
|
126
|
+
time.sleep(backoff(attempt))
|
|
127
|
+
raise BucketError("the bucket kept changing while it was read; the next run will catch up")
|
|
128
|
+
|
|
129
|
+
def _current(self) -> dict[str, tuple[str, bytes]]:
|
|
130
|
+
out = {}
|
|
131
|
+
for p in self.path.rglob("*"):
|
|
132
|
+
if p.is_file():
|
|
133
|
+
data = p.read_bytes()
|
|
134
|
+
out[p.relative_to(self.path).as_posix()] = (_sha(data), data)
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
def dirty(self) -> bool:
|
|
138
|
+
return {k: v[0] for k, v in self._current().items()} != self.files
|
|
139
|
+
|
|
140
|
+
def commit_push(self, message: str) -> str:
|
|
141
|
+
"""Upload new contents, then swap the manifest only if nobody swapped it since open(): "pushed", "unchanged",
|
|
142
|
+
or "rejected" (the caller runs its steps again on the fresh data)."""
|
|
143
|
+
now = self._current()
|
|
144
|
+
files = {k: v[0] for k, v in now.items()}
|
|
145
|
+
if files == self.files:
|
|
146
|
+
return "unchanged"
|
|
147
|
+
for name, (digest, data) in now.items():
|
|
148
|
+
if self.files.get(name) != digest and digest not in set(self.files.values()):
|
|
149
|
+
r = self._send("PUT", self._key(f"objects/{digest}"), data)
|
|
150
|
+
if r.status_code not in (200, 201):
|
|
151
|
+
raise BucketError(f"writing {name}: HTTP {r.status_code}")
|
|
152
|
+
doc = json.dumps({"files": files, "message": message, "at": datetime.now(timezone.utc).isoformat()},
|
|
153
|
+
sort_keys=True).encode()
|
|
154
|
+
cond = {"if-match": self.etag} if self.etag else {"if-none-match": "*"}
|
|
155
|
+
r = self._send("PUT", self._key(MANIFEST), doc, cond)
|
|
156
|
+
if r.status_code in (409, 412):
|
|
157
|
+
# Another run won. What this attempt uploaded stays: objects are named by content, so a run still working
|
|
158
|
+
# may have uploaded the same file and be about to publish it. A few spare objects cost less than a lost one.
|
|
159
|
+
return "rejected"
|
|
160
|
+
if r.status_code not in (200, 201):
|
|
161
|
+
raise BucketError(f"writing the manifest: HTTP {r.status_code}")
|
|
162
|
+
old = set(self.files.values()) - set(files.values())
|
|
163
|
+
self.etag, self.files = r.headers.get("etag"), files
|
|
164
|
+
# Contents no file points to any more go; a run that read the old manifest only ever reads before this.
|
|
165
|
+
for digest in old:
|
|
166
|
+
self._send("DELETE", self._key(f"objects/{digest}"))
|
|
167
|
+
return "pushed"
|
|
168
|
+
|
|
169
|
+
def squash(self, message: str) -> str:
|
|
170
|
+
return "unchanged" # a bucket keeps no history to squash
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def from_env(env: dict, path: Path, client=None) -> BucketStore | None:
|
|
174
|
+
"""The bucket store when STACKVITALS_STORAGE names one (s3://bucket/prefix), else None (the git branch)."""
|
|
175
|
+
url = (env.get("STACKVITALS_STORAGE") or "").strip()
|
|
176
|
+
if not url or url == "git":
|
|
177
|
+
return None
|
|
178
|
+
missing = [k for k in ("STACKVITALS_S3_ENDPOINT", "STACKVITALS_S3_KEY_ID", "STACKVITALS_S3_SECRET") if not env.get(k)]
|
|
179
|
+
if missing:
|
|
180
|
+
raise BucketError(f"storage {url} needs {', '.join(missing)}")
|
|
181
|
+
return BucketStore(url, path, env["STACKVITALS_S3_ENDPOINT"], env["STACKVITALS_S3_KEY_ID"], env["STACKVITALS_S3_SECRET"],
|
|
182
|
+
env.get("STACKVITALS_S3_REGION") or "auto", client)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def backoff(attempt: int) -> float:
|
|
186
|
+
# Two runs that collided shouldn't retry in step: a random wait that grows with each attempt.
|
|
187
|
+
# Tests replay collisions on purpose (pytest names the running test in this variable) and skip the wait.
|
|
188
|
+
if os.environ.get("STACKVITALS_NO_BACKOFF") == "1" or os.environ.get("PYTEST_CURRENT_TEST"):
|
|
189
|
+
return 0
|
|
190
|
+
return random.uniform(1, 4) * (attempt + 1)
|
stackvitals/core/bugs.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The bug backlog week by week (DevLake's weekly bug retro): bugs opened and closed each week, how many stay open, and
|
|
2
|
+
how old the open ones are. From GitHub issues with a bug label alone, plus Jira and Linear bugs when connected."""
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
from stackvitals.connectors.base import std_type
|
|
6
|
+
|
|
7
|
+
WEEKS = 12
|
|
8
|
+
OLDEST = 10
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _t(s: str) -> datetime:
|
|
12
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _bugs(issues: list[dict], tracked: list[dict]) -> list[dict]:
|
|
16
|
+
out = [{"src": "github", "id": i["n"], "created": i["cr"], "closed": i.get("cl")}
|
|
17
|
+
for i in issues or [] if i.get("cr") and any(std_type(x) == "BUG" for x in i.get("lb") or [])]
|
|
18
|
+
out += [{"src": i.get("src"), "id": i.get("key") or i.get("id"), "created": i["created"],
|
|
19
|
+
"closed": i.get("resolved") if i.get("cat") == "DONE" else None, "url": i.get("url")}
|
|
20
|
+
for i in tracked or [] if i.get("type") == "BUG" and i.get("created") and not i.get("canceled")]
|
|
21
|
+
return out
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def backlog(issues: list[dict], tracked: list[dict], now: datetime, weeks: int = WEEKS) -> dict | None:
|
|
25
|
+
"""{"weeks": [{"week", "opened", "closed", "open"}], "open", "median_open_days", "oldest", "sources"}, or None."""
|
|
26
|
+
bugs = _bugs(issues, tracked)
|
|
27
|
+
if not bugs:
|
|
28
|
+
return None
|
|
29
|
+
monday = (now - timedelta(days=now.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
30
|
+
rows = []
|
|
31
|
+
for k in range(weeks - 1, -1, -1):
|
|
32
|
+
start, end = monday - timedelta(weeks=k), monday - timedelta(weeks=k - 1)
|
|
33
|
+
rows.append({"week": start.strftime("%Y-%m-%d"),
|
|
34
|
+
"opened": sum(start <= _t(b["created"]) < end for b in bugs),
|
|
35
|
+
"closed": sum(bool(b["closed"]) and start <= _t(b["closed"]) < end for b in bugs),
|
|
36
|
+
"open": sum(_t(b["created"]) < min(end, now) and (not b["closed"] or _t(b["closed"]) >= min(end, now))
|
|
37
|
+
for b in bugs)})
|
|
38
|
+
still = sorted((b for b in bugs if not b["closed"]), key=lambda b: b["created"])
|
|
39
|
+
ages = sorted((now - _t(b["created"])).days for b in still)
|
|
40
|
+
return {"weeks": rows, "open": len(still), "median_open_days": ages[len(ages) // 2] if ages else None,
|
|
41
|
+
"oldest": [{"src": b["src"], "id": b["id"], "days": (now - _t(b["created"])).days, **({"url": b["url"]} if b.get("url") else {})}
|
|
42
|
+
for b in still[:OLDEST]],
|
|
43
|
+
"sources": sorted({b["src"] for b in bugs})}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""A team's working calendar ([calendar] weekends and holidays): hours between two times with the non-working days
|
|
2
|
+
(whole UTC days) left out, so a review phase isn't charged a weekend nobody worked."""
|
|
3
|
+
from datetime import date, datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _t(text: str) -> datetime:
|
|
9
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_set(cal: dict | None) -> bool:
|
|
13
|
+
return bool(cal and (cal.get("weekends") or cal.get("holidays")))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def elapsed_hours(a: str | None, b: str | None, cal: dict | None) -> float | None:
|
|
17
|
+
"""Hours from a to b, without the weekends and holidays in `cal` (None: plain clock hours)."""
|
|
18
|
+
if not a or not b:
|
|
19
|
+
return None
|
|
20
|
+
start, end = _t(a), _t(b)
|
|
21
|
+
total = (end - start).total_seconds() / 3600
|
|
22
|
+
if not is_set(cal) or end <= start:
|
|
23
|
+
return round(total, 4)
|
|
24
|
+
off_days = {DAYS.index(d) for d in cal.get("weekends") or []}
|
|
25
|
+
holidays = {date.fromisoformat(h) for h in cal.get("holidays") or []}
|
|
26
|
+
day = start.date()
|
|
27
|
+
while day <= end.date():
|
|
28
|
+
if day.weekday() in off_days or day in holidays:
|
|
29
|
+
lo = max(start, datetime(day.year, day.month, day.day, tzinfo=timezone.utc))
|
|
30
|
+
hi = min(end, datetime(day.year, day.month, day.day, tzinfo=timezone.utc) + timedelta(days=1))
|
|
31
|
+
if hi > lo:
|
|
32
|
+
total -= (hi - lo).total_seconds() / 3600
|
|
33
|
+
day += timedelta(days=1)
|
|
34
|
+
return round(max(total, 0.0), 4)
|