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,528 @@
|
|
|
1
|
+
"""Changes people ask for on GitHub, applied by the Action at the start of the job: a request issue from the app (a fenced
|
|
2
|
+
```stackvitals block), `/stackvitals` commands in comments, a request handed to a manual run (the app's token mode), and
|
|
3
|
+
a flaky-test issue closed as not planned. Only people with the right access change anything (triage for decisions,
|
|
4
|
+
write for settings), every change is logged with who and why, and the answer goes on the issue after the data is saved."""
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from stackvitals.ci.api import ActionsAPI, APIError, OutOfBudget
|
|
12
|
+
from stackvitals.core import decisions as dec
|
|
13
|
+
from stackvitals.core.config import FILE, parse
|
|
14
|
+
from stackvitals.core.digest import _code, _quiet
|
|
15
|
+
from stackvitals.core.time import iso
|
|
16
|
+
from stackvitals.core.tomledit import EditError, apply
|
|
17
|
+
|
|
18
|
+
EVENTS = ("issues", "issue_comment", "workflow_dispatch")
|
|
19
|
+
LEVELS = {"none": 0, "read": 0, "triage": 1, "write": 2, "maintain": 3, "admin": 4}
|
|
20
|
+
ITEMS = (("Flaky test: ", "test"), ("Flaky job: ", "job"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _item(title: str | None) -> tuple[str, str] | None:
|
|
24
|
+
for prefix, kind in ITEMS:
|
|
25
|
+
if (title or "").startswith(prefix):
|
|
26
|
+
return kind, title[len(prefix):]
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def seen_state(result: dict | None, kind: str, ident: str) -> dict | None:
|
|
31
|
+
"""What StackVitals said about a test or job in its newest published findings, kept with feedback on that verdict:
|
|
32
|
+
its state and the verdict a failure of it would get now."""
|
|
33
|
+
from stackvitals.ci.automation import job_key
|
|
34
|
+
from stackvitals.ci.feedback import SAFE_FLAKES, _test_verdict, _verdict
|
|
35
|
+
if not result:
|
|
36
|
+
return None
|
|
37
|
+
if kind == "test":
|
|
38
|
+
item = next((t for t in result.get("tests") or [] if t.get("id") == ident), None)
|
|
39
|
+
verdict = _test_verdict(item) if item else None
|
|
40
|
+
else:
|
|
41
|
+
jobs = result.get("jobs") or []
|
|
42
|
+
# Exact names first; the pull request comment writes "<workflow display name> / <job>", so a unique job name
|
|
43
|
+
# at the end matches too.
|
|
44
|
+
item = (next((j for j in jobs if ident in (job_key(j), j.get("job"))), None)
|
|
45
|
+
or (lambda found: found[0] if len(found) == 1 else None)([j for j in jobs if ident.endswith(f" / {j.get('job')}")]))
|
|
46
|
+
verdict = _verdict(item) if item else None
|
|
47
|
+
if verdict == "known flaky" and item.get("flake_events", 0) < SAFE_FLAKES:
|
|
48
|
+
verdict = "flaked before"
|
|
49
|
+
return {"state": item.get("state"), "flake_events": item.get("flake_events", 0), "verdict": verdict} if item else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _published(ws) -> dict | None:
|
|
53
|
+
try:
|
|
54
|
+
return json.loads((Path(ws.path) / "docs" / "data.json").read_text(encoding="utf-8"))
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _payload(env: dict) -> dict:
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(Path(env["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8"))
|
|
62
|
+
except (KeyError, OSError, ValueError):
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _level(api, login: str | None) -> int:
|
|
67
|
+
if not login:
|
|
68
|
+
return 0
|
|
69
|
+
try:
|
|
70
|
+
body = api._get(f"/repos/{api.repo}/collaborators/{login}/permission")
|
|
71
|
+
except (APIError, OutOfBudget):
|
|
72
|
+
return 0
|
|
73
|
+
# The legacy field maps triage to read and maintain to write; role_name holds the real role.
|
|
74
|
+
role = body.get("role_name")
|
|
75
|
+
return LEVELS[role] if role in LEVELS else LEVELS.get(body.get("permission") or "none", 0)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _b64(text: str) -> str:
|
|
79
|
+
return base64.b64encode(text.encode("utf-8")).decode("ascii")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _get(cfg: dict, key: str):
|
|
83
|
+
for part in key.split("."):
|
|
84
|
+
cfg = cfg[part]
|
|
85
|
+
return cfg
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _shown(value) -> str:
|
|
89
|
+
return "(not set)" if value is None else json.dumps(value)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _changes_line(before: dict, after: dict, keys) -> str:
|
|
93
|
+
return "; ".join(f"{_code(k)}: {_code(_shown(_get(before, k)))} to {_code(_shown(_get(after, k)))}" for k in keys)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _moves(before: dict, settings: dict) -> dict:
|
|
97
|
+
# What changed per key for the log; a null in the request puts the default back.
|
|
98
|
+
return {k: f"{_shown(_get(before, k))} to {'the default' if v is None else json.dumps(v)}" for k, v in settings.items()}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _apply_settings(api, settings: dict, login: str, number: int | None, now: datetime) -> tuple[str, dict | None, dict]:
|
|
102
|
+
"""(answer, the new settings when they took effect now, what changed per key for the log): a commit on the default
|
|
103
|
+
branch, else a branch with a pull request (or a link to open one) when the branch is protected."""
|
|
104
|
+
info = api._get(f"/repos/{api.repo}")
|
|
105
|
+
branch = info.get("default_branch") or "main"
|
|
106
|
+
try:
|
|
107
|
+
got = api._get(f"/repos/{api.repo}/contents/{FILE}", {"ref": branch})
|
|
108
|
+
sha, text = got.get("sha"), base64.b64decode(got.get("content") or "").decode("utf-8")
|
|
109
|
+
except APIError as exc:
|
|
110
|
+
if "404" not in str(exc):
|
|
111
|
+
raise
|
|
112
|
+
sha, text = None, ""
|
|
113
|
+
try:
|
|
114
|
+
new = apply(text, settings)
|
|
115
|
+
except EditError as exc:
|
|
116
|
+
return f"Nothing was changed: {_quiet(exc)}", None, {}
|
|
117
|
+
if new == text:
|
|
118
|
+
return f"{FILE} already says that; nothing to change.", None, {}
|
|
119
|
+
what = _changes_line(parse(text)[0], parse(new)[0], settings)
|
|
120
|
+
moves = _moves(parse(text)[0], settings)
|
|
121
|
+
proposed = {k: f"{m}, proposed in a pull request" for k, m in moves.items()}
|
|
122
|
+
asked = f"asked in #{number}" if number else "asked from the StackVitals app"
|
|
123
|
+
body = {"message": f"chore(stackvitals): Update settings ({asked} by {login})", "content": _b64(new), "branch": branch,
|
|
124
|
+
**({"sha": sha} if sha else {})}
|
|
125
|
+
try:
|
|
126
|
+
api.send("PUT", f"/repos/{api.repo}/contents/{FILE}", body)
|
|
127
|
+
return f"Changed in `{FILE}` on `{branch}`: {what}. It applies from this run.", parse(new)[0], moves
|
|
128
|
+
except APIError as exc:
|
|
129
|
+
if not any(code in str(exc) for code in ("409", "422", "403")):
|
|
130
|
+
raise
|
|
131
|
+
head = f"stackvitals/settings-{number or now.strftime('%Y%m%d%H%M%S')}"
|
|
132
|
+
base = api._get(f"/repos/{api.repo}/git/ref/heads/{branch}")["object"]["sha"]
|
|
133
|
+
try:
|
|
134
|
+
api.send("POST", f"/repos/{api.repo}/git/refs", {"ref": f"refs/heads/{head}", "sha": base})
|
|
135
|
+
except APIError:
|
|
136
|
+
pass # left by an earlier attempt of this same request
|
|
137
|
+
api.send("PUT", f"/repos/{api.repo}/contents/{FILE}", {**body, "branch": head})
|
|
138
|
+
title = f"Update StackVitals settings ({asked})"
|
|
139
|
+
try:
|
|
140
|
+
pr = api.send("POST", f"/repos/{api.repo}/pulls", {"title": title, "head": head, "base": branch,
|
|
141
|
+
"body": f"Settings change {asked} by {login}: {what}."})
|
|
142
|
+
return f"`{branch}` is protected, so the change waits in a pull request: {pr.get('html_url')} ({what}).", None, proposed
|
|
143
|
+
except APIError:
|
|
144
|
+
link = f"https://github.com/{api.repo}/compare/{branch}...{head}?quick_pull=1"
|
|
145
|
+
return (f"`{branch}` is protected and this repo doesn't let Actions open pull requests; open it here: {link} ({what}).",
|
|
146
|
+
None, proposed)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _decided(changes: list[dec.Decision]) -> str:
|
|
150
|
+
words = {"quarantine": "quarantined", "unquarantine": "taken out of quarantine", "mute": "muted", "unmute": "unmuted",
|
|
151
|
+
"acknowledge": "acknowledged", "snooze": "snoozed", "reopen": "reopened", "exclude": "excluded from the numbers",
|
|
152
|
+
"include": "counted again", "accept": "accepted as a known risk",
|
|
153
|
+
"confirm": "confirmed: StackVitals' verdict was right", "dispute": "disputed: StackVitals' verdict was wrong"}
|
|
154
|
+
undo = {"quarantine": "unquarantine", "mute": "unmute", "snooze": "reopen", "acknowledge": "reopen", "exclude": "include",
|
|
155
|
+
"accept": "reopen"}
|
|
156
|
+
out = []
|
|
157
|
+
for d in changes:
|
|
158
|
+
line = f"{d.kind} {_code(d.id)} {words[d.verb]}" + (f" until {d.until}" if d.until else "")
|
|
159
|
+
if d.reason:
|
|
160
|
+
line += f" ({_quiet(d.reason)})"
|
|
161
|
+
if d.verb in undo:
|
|
162
|
+
line += f"; undo with `/stackvitals {undo[d.verb]} {d.kind}:{d.id.replace('`', '')}`"
|
|
163
|
+
out.append("- " + line)
|
|
164
|
+
return "\n".join(out)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
SWEEP = "decisions/requests.json"
|
|
168
|
+
LOOKBACK = timedelta(hours=6)
|
|
169
|
+
TRUSTED = ("OWNER", "MEMBER", "COLLABORATOR")
|
|
170
|
+
DONE_MAX = 500
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _commanded(body: str | None) -> bool:
|
|
174
|
+
return any(line.strip().lower().startswith("/stackvitals") for line in (body or "").splitlines())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _missed(api, since: str, done: set) -> list[dict]:
|
|
178
|
+
"""Commands and request issues from people with access that no run applied (GitHub cancels a waiting run when a
|
|
179
|
+
newer one queues): the newest comments and open issues since `since`, two requests."""
|
|
180
|
+
out = []
|
|
181
|
+
comments = api._get(f"/repos/{api.repo}/issues/comments", {"since": since, "sort": "created", "direction": "desc",
|
|
182
|
+
"per_page": 100}) or []
|
|
183
|
+
for c in reversed(comments):
|
|
184
|
+
key = f"comment:{c.get('id')}"
|
|
185
|
+
if (key not in done and (c.get("created_at") or "") >= since and c.get("author_association") in TRUSTED
|
|
186
|
+
and _commanded(c.get("body"))):
|
|
187
|
+
number = int(str(c.get("issue_url") or "/0").rsplit("/", 1)[-1] or 0)
|
|
188
|
+
out.append({"key": key, "kind": "comment", "number": number, "login": (c.get("user") or {}).get("login"),
|
|
189
|
+
"body": c.get("body") or ""})
|
|
190
|
+
for i in reversed(api._get(f"/repos/{api.repo}/issues", {"state": "open", "since": since, "per_page": 100}) or []):
|
|
191
|
+
key = f"issue:{i.get('number')}"
|
|
192
|
+
if (key not in done and "pull_request" not in i and (i.get("created_at") or "") >= since
|
|
193
|
+
and i.get("author_association") in TRUSTED and "```stackvitals" in (i.get("body") or "")):
|
|
194
|
+
out.append({"key": key, "kind": "issue", "number": i["number"], "login": (i.get("user") or {}).get("login"),
|
|
195
|
+
"body": i.get("body") or ""})
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _from_event(event: str, payload: dict, env: dict) -> dict | None:
|
|
200
|
+
issue = payload.get("issue") or {}
|
|
201
|
+
if event == "issues" and payload.get("action") == "opened":
|
|
202
|
+
return {"key": f"issue:{issue.get('number')}", "kind": "issue", "number": issue.get("number"),
|
|
203
|
+
"login": (issue.get("user") or {}).get("login"), "body": issue.get("body") or ""}
|
|
204
|
+
if event == "issue_comment" and payload.get("action") == "created":
|
|
205
|
+
comment = payload.get("comment") or {}
|
|
206
|
+
# On a flaky-test issue the command is about its test; on a pull request, about that pull request.
|
|
207
|
+
default = _item(issue.get("title")) or (("pr", str(issue.get("number"))) if issue.get("pull_request") else None)
|
|
208
|
+
return {"key": f"comment:{comment.get('id')}", "kind": "comment", "number": issue.get("number"),
|
|
209
|
+
"login": (comment.get("user") or {}).get("login"), "body": comment.get("body") or "", "default": default}
|
|
210
|
+
if event == "workflow_dispatch":
|
|
211
|
+
raw = ((payload.get("inputs") or {}).get("request") or "").strip()
|
|
212
|
+
if raw:
|
|
213
|
+
return {"key": None, "kind": "dispatch", "number": None, "body": raw,
|
|
214
|
+
"login": (payload.get("sender") or {}).get("login") or env.get("GITHUB_ACTOR")}
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
APPROVE = "/stackvitals approve"
|
|
219
|
+
WAITING = "Waiting for approval:"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def approval_code(body: str | None) -> str:
|
|
223
|
+
# Ties an approval to the request as it reads now: an edit to the issue changes the code, so the approval must be given again.
|
|
224
|
+
return hashlib.sha256(" ".join(str(body or "").split()).encode()).hexdigest()[:8]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _golden(env: dict, api, golden: dict, login: str, approver: str | None = None, body: str | None = None,
|
|
228
|
+
issue: bool = True) -> str:
|
|
229
|
+
"""Runs a Portal golden path with the org App, from the portal repo's portal.toml; says what happened or why not. A
|
|
230
|
+
path with `approvals = 1` waits until someone else with write access comments the approval."""
|
|
231
|
+
from stackvitals.portal.golden import GoldenError, load_paths, run
|
|
232
|
+
token = (env.get("STACKVITALS_PORTAL_TOKEN") or "").strip()
|
|
233
|
+
if not token:
|
|
234
|
+
return "Golden paths run with the Portal's org App (the portal-token input); nothing was run."
|
|
235
|
+
try:
|
|
236
|
+
paths = load_paths(api.file("portal.toml", None).decode("utf-8", "replace"))
|
|
237
|
+
except (APIError, GoldenError, ValueError) as exc:
|
|
238
|
+
return f"The golden paths in portal.toml couldn't be read ({_quiet(exc)}); nothing was run."
|
|
239
|
+
path = paths.get(golden["path"])
|
|
240
|
+
if not path:
|
|
241
|
+
return f"portal.toml has no golden path {_code(golden['path'])}; nothing was run."
|
|
242
|
+
if path.get("approvals") and not approver:
|
|
243
|
+
if not issue:
|
|
244
|
+
return (f"Golden path {_code(path['id'])} needs someone else's approval, given on a request issue: send it "
|
|
245
|
+
"from the app as a request (not \"run now\"); nothing was run.")
|
|
246
|
+
return (f"{WAITING} golden path {_code(path['id'])} runs once someone else with write access to this repo "
|
|
247
|
+
f"comments `{APPROVE} {approval_code(body)}` here (the code changes if the request is edited).")
|
|
248
|
+
org = (env.get("STACKVITALS_PORTAL_ORG") or "").strip() or api.repo.split("/")[0]
|
|
249
|
+
try:
|
|
250
|
+
text, url = run(ActionsAPI(token, api.repo, max_requests=60), api, api.repo, org, path, golden["inputs"], login)
|
|
251
|
+
except (GoldenError, APIError, OutOfBudget) as exc:
|
|
252
|
+
return f"Golden path {_code(path['id'])} stopped: {_quiet(str(exc)[:200])}"
|
|
253
|
+
return f"{text} {url}".strip()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _approve(ws, api, store, item: dict, now: datetime, say, env: dict) -> dict:
|
|
257
|
+
"""`/stackvitals approve` on a waiting golden path request: someone with write access other than the requester lets it
|
|
258
|
+
run, as the requester (whose own access the golden path still checks)."""
|
|
259
|
+
number, approver = item.get("number"), item.get("login")
|
|
260
|
+
issue = api._get(f"/repos/{api.repo}/issues/{number}") if number else {}
|
|
261
|
+
requester = (issue.get("user") or {}).get("login")
|
|
262
|
+
try:
|
|
263
|
+
req = dec.parse_request(issue.get("body") or "", now.date())
|
|
264
|
+
except dec.CommandError:
|
|
265
|
+
req = None
|
|
266
|
+
if not req or not req.golden or issue.get("state") != "open":
|
|
267
|
+
say(number, "There is no golden path waiting for approval here; nothing was run.", None)
|
|
268
|
+
return {"decisions": 0, "settings": False}
|
|
269
|
+
if approver == requester:
|
|
270
|
+
say(number, "A golden path needs someone other than the person who asked for it to approve; nothing was run.", None)
|
|
271
|
+
return {"decisions": 0, "settings": False, "refused": True}
|
|
272
|
+
if _level(api, approver) < 2 or _level(api, requester) < 2:
|
|
273
|
+
say(number, "Approving needs write access to this repo, for both the approver and the requester; nothing was run.", None)
|
|
274
|
+
return {"decisions": 0, "settings": False, "refused": True}
|
|
275
|
+
given = (item.get("body") or "").strip()[len(APPROVE):].split()
|
|
276
|
+
code = approval_code(issue.get("body"))
|
|
277
|
+
if not given or given[0].lower() != code:
|
|
278
|
+
say(number, f"The approval needs the request's current code: comment `{APPROVE} {code}` (it changes when the request "
|
|
279
|
+
"is edited, so what was read is what runs); nothing was run.", None)
|
|
280
|
+
return {"decisions": 0, "settings": False, "refused": True}
|
|
281
|
+
memo = getattr(ws, "memo", {})
|
|
282
|
+
key = ("golden", f"approved:{number}")
|
|
283
|
+
if key not in memo:
|
|
284
|
+
memo[key] = _golden(env, api, req.golden, requester, approver=approver)
|
|
285
|
+
store.record([dec.Decision("run", "golden", req.golden["path"], None, f"approved by {approver}: {memo[key][:180]}")],
|
|
286
|
+
by=requester, source=f"comment on #{number}", now=now)
|
|
287
|
+
say(number, f"Approved by {approver}. {memo[key]}", "completed")
|
|
288
|
+
return {"decisions": 1, "settings": False}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _apply(ws, api, store, item: dict, now: datetime, say, env: dict | None = None) -> dict:
|
|
292
|
+
"""One request or command: parsed, checked against the sender's access, recorded, settings committed; `say` answers."""
|
|
293
|
+
today, number, login = now.date(), item.get("number"), item.get("login")
|
|
294
|
+
source = {"issue": f"issue #{number}", "comment": f"comment on #{number}"}.get(item["kind"], "manual run")
|
|
295
|
+
if item["kind"] == "comment" and item["body"].strip().lower().startswith(APPROVE):
|
|
296
|
+
return _approve(ws, api, store, item, now, say, env or {})
|
|
297
|
+
try:
|
|
298
|
+
asked = _check_command(item["body"]) if item["kind"] == "comment" else None
|
|
299
|
+
again = _resync_command(item["body"]) if item["kind"] == "comment" else None
|
|
300
|
+
if asked is not None:
|
|
301
|
+
req = dec.Request([], {}, check=asked)
|
|
302
|
+
elif again is not None:
|
|
303
|
+
req = dec.Request([], {}, resync=again)
|
|
304
|
+
elif item["kind"] == "comment":
|
|
305
|
+
default = item.get("default")
|
|
306
|
+
if "default" not in item and number:
|
|
307
|
+
found = api._get(f"/repos/{api.repo}/issues/{number}")
|
|
308
|
+
default = _item(found.get("title")) or (("pr", str(number)) if found.get("pull_request") else None)
|
|
309
|
+
changes = dec.parse_command(item["body"], today, default=default)
|
|
310
|
+
req = dec.Request(changes, {}) if changes else None
|
|
311
|
+
else:
|
|
312
|
+
raw = item["body"]
|
|
313
|
+
block = raw if item["kind"] == "issue" or "```stackvitals" in raw else f"```stackvitals\n{raw}\n```"
|
|
314
|
+
req = dec.parse_request(block, today)
|
|
315
|
+
except dec.CommandError as exc:
|
|
316
|
+
say(number, f"StackVitals couldn't read that: {_quiet(exc)} Nothing was changed.", None)
|
|
317
|
+
return {"decisions": 0, "settings": False, "error": str(exc)}
|
|
318
|
+
if req is None or not (req.changes or req.settings or req.golden or req.check or req.resync):
|
|
319
|
+
return {"decisions": 0, "settings": False}
|
|
320
|
+
# A connection test uses the repo's keys and may post to its chat, and a re-sync spends their limits: write access.
|
|
321
|
+
need = 2 if req.settings or req.golden or req.check or req.resync else 1
|
|
322
|
+
if _level(api, login) < need:
|
|
323
|
+
say(number, ("Only people with write access to this repo can change its StackVitals settings" if need == 2 else
|
|
324
|
+
"Only people with triage or write access can make StackVitals decisions here") + "; nothing was changed.",
|
|
325
|
+
None)
|
|
326
|
+
return {"decisions": 0, "settings": False, "refused": True}
|
|
327
|
+
lines, changed, moves = [], False, {}
|
|
328
|
+
if req.changes:
|
|
329
|
+
feedback = [c for c in req.changes if c.verb in dec.FEEDBACK]
|
|
330
|
+
published = _published(ws) if feedback else None
|
|
331
|
+
seen = {(c.kind, c.id): seen_state(published, c.kind, c.id) for c in feedback}
|
|
332
|
+
store.record(req.changes, by=login, source=source, now=now, seen=seen)
|
|
333
|
+
lines += ["Recorded:", _decided(req.changes)]
|
|
334
|
+
if req.settings:
|
|
335
|
+
memo = getattr(ws, "memo", {})
|
|
336
|
+
key = ("settings", item["key"] or source)
|
|
337
|
+
if key not in memo:
|
|
338
|
+
# Once per job: a rejected data push runs this step again, and the file on GitHub already changed.
|
|
339
|
+
memo[key] = _apply_settings(api, req.settings, login, number, now)
|
|
340
|
+
text, cfg, moves = memo[key]
|
|
341
|
+
lines.append(text)
|
|
342
|
+
# Settings changes join the log too, so the app's Activity shows who changed what, and how.
|
|
343
|
+
store.record([dec.Decision("set", "setting", k, None, why) for k, why in moves.items()], by=login, source=source, now=now)
|
|
344
|
+
if cfg is not None:
|
|
345
|
+
ws.cache["settings"], changed = cfg, True
|
|
346
|
+
if req.golden:
|
|
347
|
+
memo = getattr(ws, "memo", {})
|
|
348
|
+
key = ("golden", item["key"] or source)
|
|
349
|
+
if key not in memo:
|
|
350
|
+
# Once per job, like settings: a retried data push must never create a repo or a PR twice.
|
|
351
|
+
memo[key] = _golden(env or {}, api, req.golden, login, body=item.get("body"), issue=item["kind"] == "issue")
|
|
352
|
+
lines.append(memo[key])
|
|
353
|
+
store.record([dec.Decision("run", "golden", req.golden["path"], None, memo[key][:200])], by=login, source=source,
|
|
354
|
+
now=now)
|
|
355
|
+
checked = None
|
|
356
|
+
if req.check:
|
|
357
|
+
memo = getattr(ws, "memo", {})
|
|
358
|
+
key = ("check", item["key"] or source)
|
|
359
|
+
if key not in memo:
|
|
360
|
+
# Once per job: a retried data push must not post the chat test message twice.
|
|
361
|
+
memo[key] = _run_checks(ws, req.check, env or {})
|
|
362
|
+
checked = memo[key]
|
|
363
|
+
if isinstance(checked, dict):
|
|
364
|
+
from stackvitals.connectors import check, public
|
|
365
|
+
check.save(ws.path, checked, public(ws))
|
|
366
|
+
shown = check.shown(checked, public(ws))
|
|
367
|
+
lines += ["Connection checks:", *(f"- {check.line(n, r)}" for n, r in shown.items())] if shown else \
|
|
368
|
+
["No connector is set up yet, so there was nothing to test."]
|
|
369
|
+
else:
|
|
370
|
+
lines.append(checked)
|
|
371
|
+
resynced = 0
|
|
372
|
+
if req.resync:
|
|
373
|
+
from stackvitals.connectors import ConnectorError
|
|
374
|
+
from stackvitals.core.resync import rewind
|
|
375
|
+
lines.append("Read again:")
|
|
376
|
+
for r in req.resync:
|
|
377
|
+
# The connectors step runs after this one, so the same run reads the window again.
|
|
378
|
+
try:
|
|
379
|
+
lines.append(f"- {rewind(ws.path, r['source'].strip().lower(), r['since'], now)}")
|
|
380
|
+
resynced += 1
|
|
381
|
+
except (ConnectorError, ValueError) as exc:
|
|
382
|
+
lines.append(f"- {r['source']}: {_quiet(exc)}")
|
|
383
|
+
store.record([dec.Decision("run", "resync", r["source"], None, f"since {r['since'] or 'the whole window'}")
|
|
384
|
+
for r in req.resync], by=login, source=source, now=now)
|
|
385
|
+
waiting = bool(req.golden) and any(line.startswith(WAITING) for line in lines)
|
|
386
|
+
close = (None if waiting else "completed" if req.changes or moves or req.golden or req.check or resynced
|
|
387
|
+
else "not_planned") if item["kind"] == "issue" else None
|
|
388
|
+
say(number, "\n".join(lines), close)
|
|
389
|
+
out = {"decisions": len(req.changes), "settings": changed, **({"resynced": resynced} if req.resync else {})}
|
|
390
|
+
if isinstance(checked, dict):
|
|
391
|
+
out.update(checks=len(checked), working=sum(1 for r in checked.values() if r["ok"]))
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _run_checks(ws, asked, env: dict) -> dict | str:
|
|
396
|
+
"""The asked connection checks, or a sentence when the names can't be read."""
|
|
397
|
+
from stackvitals.connectors import check, public
|
|
398
|
+
cfg = ws.cache.get("settings") or parse("")[0]
|
|
399
|
+
try:
|
|
400
|
+
names = check.resolve(asked, cfg, env)
|
|
401
|
+
except ValueError as exc:
|
|
402
|
+
return f"Nothing was tested: {_quiet(exc)}"
|
|
403
|
+
# A public repo's answers name no channel, team or bucket: they land on a readable branch and issue.
|
|
404
|
+
return check.run(names, cfg, env, public=public(ws))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _check_command(body: str | None):
|
|
408
|
+
"""`/stackvitals check jira slack` (or `check` / `check all`): the names asked for, else None."""
|
|
409
|
+
for line in (body or "").splitlines():
|
|
410
|
+
words = line.strip().split()
|
|
411
|
+
if words and words[0].lower() == "/stackvitals":
|
|
412
|
+
if len(words) > 1 and words[1].lower() == "check":
|
|
413
|
+
rest = [w.strip(",") for w in words[2:] if w.strip(",")]
|
|
414
|
+
return "all" if not rest or [w.lower() for w in rest] == ["all"] else rest
|
|
415
|
+
return None
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _resync_command(body: str | None) -> list | None:
|
|
420
|
+
"""`/stackvitals resync jira linear since 2026-09-01`: the services to read again and from when, else None."""
|
|
421
|
+
for line in (body or "").splitlines():
|
|
422
|
+
words = [w.strip(",") for w in line.strip().split() if w.strip(",")]
|
|
423
|
+
if words and words[0].lower() == "/stackvitals":
|
|
424
|
+
if len(words) < 3 or words[1].lower() != "resync":
|
|
425
|
+
return None
|
|
426
|
+
since = words[words.index("since") + 1] if "since" in words[:-1] else None
|
|
427
|
+
names = [w.lower() for w in words[2:] if w not in ("since", since)]
|
|
428
|
+
return [{"source": n, "since": since} for n in names] or None
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def check_only(env: dict) -> bool:
|
|
433
|
+
"""Whether this run's own request only asks for connection checks, so the job can skip every module."""
|
|
434
|
+
item = _from_event(env.get("GITHUB_EVENT_NAME"), _payload(env), env)
|
|
435
|
+
if not item:
|
|
436
|
+
return False
|
|
437
|
+
if item["kind"] == "comment":
|
|
438
|
+
return _check_command(item["body"]) is not None
|
|
439
|
+
raw = item["body"]
|
|
440
|
+
block = raw if item["kind"] == "issue" or "```stackvitals" in raw else f"```stackvitals\n{raw}\n```"
|
|
441
|
+
try:
|
|
442
|
+
req = dec.parse_request(block, datetime.now().date())
|
|
443
|
+
except dec.CommandError:
|
|
444
|
+
return False
|
|
445
|
+
return bool(req and req.check and not (req.changes or req.settings or req.golden))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _closed_or_reopened(store, payload: dict, now: datetime, api=None) -> dict | None:
|
|
449
|
+
"""A flaky-test issue closed as not planned: the team lives with it; reopened: that decision is undone."""
|
|
450
|
+
issue = payload.get("issue") or {}
|
|
451
|
+
item = _item(issue.get("title"))
|
|
452
|
+
if not (item and any((label or {}).get("name") == "flaky-test" for label in issue.get("labels") or [])):
|
|
453
|
+
return None
|
|
454
|
+
by, source = (payload.get("sender") or {}).get("login") or "?", f"issue #{issue.get('number')}"
|
|
455
|
+
if payload.get("action") == "closed":
|
|
456
|
+
if issue.get("state_reason") != "not_planned":
|
|
457
|
+
return None
|
|
458
|
+
# An issue's author can close it too; only triage or write access makes it a team decision.
|
|
459
|
+
if api is None or _level(api, by if by != "?" else None) < 1:
|
|
460
|
+
print(f"{by} closed {source} as not planned without triage or write access; no decision was recorded.")
|
|
461
|
+
return {"decisions": 0, "settings": False, "refused": True}
|
|
462
|
+
store.record([dec.Decision("acknowledge", item[0], item[1], None, "closed as not planned")], by=by, source=source, now=now)
|
|
463
|
+
else:
|
|
464
|
+
current = store.state(now.date()).get(f"{item[0]}:{item[1]}") or {}
|
|
465
|
+
if current.get("verb") != "acknowledge":
|
|
466
|
+
return None
|
|
467
|
+
store.record([dec.Decision("reopen", item[0], item[1], None, "issue reopened")], by=by, source=source, now=now)
|
|
468
|
+
return {"decisions": 1, "settings": False}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def requests_step(ws, env: dict, now: datetime, make_api=ActionsAPI) -> dict | None:
|
|
472
|
+
event, payload = env.get("GITHUB_EVENT_NAME"), _payload(env)
|
|
473
|
+
store = dec.Store(ws.path)
|
|
474
|
+
api = make_api(env.get("STACKVITALS_TOKEN") or env.get("GITHUB_TOKEN"), ws.repo, max_requests=40)
|
|
475
|
+
if event == "issues" and payload.get("action") in ("closed", "reopened"):
|
|
476
|
+
return _closed_or_reopened(store, payload, now, api)
|
|
477
|
+
replies: list[tuple[int, str, str | None]] = []
|
|
478
|
+
unanswered: list[str] = []
|
|
479
|
+
|
|
480
|
+
def say(number, text, close):
|
|
481
|
+
if number:
|
|
482
|
+
replies.append((number, text, close))
|
|
483
|
+
else:
|
|
484
|
+
unanswered.append(text)
|
|
485
|
+
|
|
486
|
+
def answer():
|
|
487
|
+
for n, text, close in replies:
|
|
488
|
+
api.send("POST", f"/repos/{ws.repo}/issues/{n}/comments", {"body": text})
|
|
489
|
+
if close:
|
|
490
|
+
api.send("PATCH", f"/repos/{ws.repo}/issues/{n}", {"state": "closed", "state_reason": close})
|
|
491
|
+
# A manual run has no issue to answer on: its answer goes to the job summary.
|
|
492
|
+
for text in unanswered:
|
|
493
|
+
print(text)
|
|
494
|
+
if env.get("GITHUB_STEP_SUMMARY"):
|
|
495
|
+
with open(env["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f:
|
|
496
|
+
f.write(f"### StackVitals request\n{text}\n")
|
|
497
|
+
ws.after_commit(answer, "Requests")
|
|
498
|
+
path = ws.path / SWEEP
|
|
499
|
+
try:
|
|
500
|
+
kept = json.loads(path.read_text(encoding="utf-8"))
|
|
501
|
+
except (OSError, ValueError):
|
|
502
|
+
kept = None
|
|
503
|
+
done = list((kept or {}).get("done") or [])
|
|
504
|
+
own = _from_event(event, payload, env)
|
|
505
|
+
todo = [own] if own else []
|
|
506
|
+
if kept is not None:
|
|
507
|
+
since = max(now - LOOKBACK, datetime.fromisoformat(kept["since"].replace("Z", "+00:00")))
|
|
508
|
+
try:
|
|
509
|
+
todo += [m for m in _missed(api, iso(since), set(done)) if m["key"] != (own or {}).get("key")]
|
|
510
|
+
except (APIError, OutOfBudget):
|
|
511
|
+
pass # the next run looks again
|
|
512
|
+
totals = {"decisions": 0, "settings": False}
|
|
513
|
+
for item in todo:
|
|
514
|
+
out = _apply(ws, api, store, item, now, say, env)
|
|
515
|
+
totals["decisions"] += out["decisions"]
|
|
516
|
+
totals["settings"] = totals["settings"] or out["settings"]
|
|
517
|
+
totals.update({k: v for k, v in out.items() if k in ("error", "refused")})
|
|
518
|
+
for k in ("checks", "working", "resynced"):
|
|
519
|
+
if k in out:
|
|
520
|
+
totals[k] = totals.get(k, 0) + out[k]
|
|
521
|
+
if item["key"]:
|
|
522
|
+
done.append(item["key"])
|
|
523
|
+
# Written only when something new was handled (tracking starts with the first), so a quiet run commits nothing.
|
|
524
|
+
if len(done) != len((kept or {}).get("done") or []):
|
|
525
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
526
|
+
path.write_text(json.dumps({"since": (kept or {}).get("since") or iso(now), "done": done[-DONE_MAX:]}, indent=1),
|
|
527
|
+
encoding="utf-8")
|
|
528
|
+
return totals if todo else None
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Read one source again from a date: CI Health's runs, Team Flow's pull requests or a connected service. What was kept
|
|
2
|
+
stays; records read again replace theirs by id, so nothing is counted twice."""
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# Names people use for the two GitHub sources.
|
|
7
|
+
CI, FLOW = {"ci", "ci health", "runs"}, {"flow", "team flow", "prs", "pull requests"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def rewind(root: Path, source: str, since: str | None, now: datetime) -> str:
|
|
11
|
+
"""Make the next collection of `source` read again from `since` (None: its whole window); a sentence saying what."""
|
|
12
|
+
from stackvitals.connectors import ConnectorError, rewind as rewind_connector
|
|
13
|
+
name = source.strip().lower()
|
|
14
|
+
day = date.fromisoformat(since) if since else None
|
|
15
|
+
if day and day > now.date():
|
|
16
|
+
raise ConnectorError(f"{since} is in the future; pick a past date to read again from.")
|
|
17
|
+
if name in CI:
|
|
18
|
+
from stackvitals.ci.store import DataStore
|
|
19
|
+
store = DataStore(root)
|
|
20
|
+
state = store.state()
|
|
21
|
+
# Days marked done are listed again; runs already kept are skipped, so only missing ones are fetched.
|
|
22
|
+
for key in ("scanned", "scanned_tests"):
|
|
23
|
+
state[key] = {d: v for d, v in (state.get(key) or {}).items() if day and d < day.isoformat()}
|
|
24
|
+
store.save_state(state)
|
|
25
|
+
return f"CI Health reads its runs again from {day.isoformat() if day else 'the start of its window'}."
|
|
26
|
+
if name in FLOW:
|
|
27
|
+
from stackvitals.flow.store import FlowStore
|
|
28
|
+
store = FlowStore(root)
|
|
29
|
+
state = {k: v for k, v in store.state().items() if k != "synced_up_to"}
|
|
30
|
+
if day:
|
|
31
|
+
state["synced_up_to"] = f"{day.isoformat()}T00:00:00Z"
|
|
32
|
+
store.save_state(state)
|
|
33
|
+
return (f"Team Flow reads pull requests updated since {day.isoformat()} again." if day else
|
|
34
|
+
"Team Flow reads its whole window of pull requests again.")
|
|
35
|
+
return rewind_connector(root, name.replace(".", "_") if name == "incident.io" else name, since, now)
|