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,285 @@
|
|
|
1
|
+
"""The suite's typed entities, with Apache DevLake's domain names and enums (so its SQL runs on our exports) and
|
|
2
|
+
OpenTelemetry's attribute names for CI/CD and VCS columns. Built from the records the modules already store, so nothing
|
|
3
|
+
is collected twice; new code reads these, old code keeps its dicts until it moves over."""
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from stackvitals.core.actors import kind_of
|
|
7
|
+
from stackvitals.core.time import secs
|
|
8
|
+
|
|
9
|
+
PR_STATES = ("OPEN", "MERGED", "CLOSED")
|
|
10
|
+
# DevLake's cicd result and status enums; GitHub's conclusions fold into them.
|
|
11
|
+
CICD_RESULTS = {"success": "SUCCESS", "failure": "FAILURE", "timed_out": "FAILURE", "startup_failure": "FAILURE",
|
|
12
|
+
"cancelled": "ABORT", "action_required": "MANUAL"}
|
|
13
|
+
# GitHub's own word for each result, for DevLake's original_result column.
|
|
14
|
+
ORIGINAL = {"SUCCESS": "success", "FAILURE": "failure", "ABORT": "cancelled", "MANUAL": "action_required"}
|
|
15
|
+
ISSUE_TYPES = ("REQUIREMENT", "BUG", "INCIDENT")
|
|
16
|
+
INCIDENT_LABELS = ("incident", "outage", "sev1", "sev2", "p0")
|
|
17
|
+
BUG_LABELS = ("bug", "defect", "regression")
|
|
18
|
+
EVENT_KINDS = {"ready": "READY", "draft": "DRAFT", "req": "REVIEW_REQUEST", "unreq": "REVIEW_REQUEST_REMOVED",
|
|
19
|
+
"review": "REVIEW", "merged": "MERGED", "closed": "CLOSED"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def entity_id(entity: str, repo: str, key, source: str = "github") -> str:
|
|
23
|
+
# DevLake's id form: plugin:Entity:connection:id, with the repo as the connection.
|
|
24
|
+
return f"{source}:{entity}:{repo}:{key}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cicd_result(conclusion: str | None) -> str:
|
|
28
|
+
return CICD_RESULTS.get(conclusion or "", "OTHER")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ChangeEvent:
|
|
33
|
+
kind: str # READY, DRAFT, REVIEW_REQUEST, REVIEW_REQUEST_REMOVED, COMMENT, REVIEW, MERGED, CLOSED
|
|
34
|
+
at: str
|
|
35
|
+
actor: str | None = None
|
|
36
|
+
actor_kind: str = "unknown"
|
|
37
|
+
state: str | None = None # review state: APPROVED, CHANGES_REQUESTED, COMMENTED
|
|
38
|
+
assoc: str | None = None
|
|
39
|
+
raised: bool = False # the review or comment asked the author something
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ChangeRequest:
|
|
44
|
+
"""DevLake's pull_requests entity with its timeline."""
|
|
45
|
+
id: str
|
|
46
|
+
repo: str
|
|
47
|
+
number: int
|
|
48
|
+
title: str
|
|
49
|
+
state: str
|
|
50
|
+
draft: bool
|
|
51
|
+
author: str | None
|
|
52
|
+
author_kind: str
|
|
53
|
+
author_assoc: str | None
|
|
54
|
+
created_at: str | None
|
|
55
|
+
ready_at: str | None
|
|
56
|
+
merged_at: str | None
|
|
57
|
+
closed_at: str | None
|
|
58
|
+
updated_at: str | None
|
|
59
|
+
additions: int | None
|
|
60
|
+
deletions: int | None
|
|
61
|
+
changed_files: int | None
|
|
62
|
+
base_ref: str | None
|
|
63
|
+
head_ref: str | None
|
|
64
|
+
head_owner: str | None
|
|
65
|
+
last_commit_at: str | None
|
|
66
|
+
paths: list[str] = field(default_factory=list)
|
|
67
|
+
labels: list[str] = field(default_factory=list)
|
|
68
|
+
requested: list[str] = field(default_factory=list)
|
|
69
|
+
events: list[ChangeEvent] = field(default_factory=list)
|
|
70
|
+
linked_issue: bool = False
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_flow(cls, rec: dict, repo: str) -> "ChangeRequest":
|
|
74
|
+
from stackvitals.flow.analyze import ready_at
|
|
75
|
+
events = []
|
|
76
|
+
for e in rec.get("ev") or []:
|
|
77
|
+
kind = EVENT_KINDS.get(e[0])
|
|
78
|
+
if not kind:
|
|
79
|
+
continue
|
|
80
|
+
actor = e[2] if len(e) > 2 else None
|
|
81
|
+
if e[0] == "review":
|
|
82
|
+
events.append(ChangeEvent(kind, e[1], actor, kind_of(actor, e[5] if len(e) > 5 else None), e[3],
|
|
83
|
+
e[4] if len(e) > 4 else None, bool(e[6]) if len(e) > 6 else False))
|
|
84
|
+
else:
|
|
85
|
+
events.append(ChangeEvent(kind, e[1], actor, kind_of(actor) if actor else "unknown"))
|
|
86
|
+
for c in rec.get("cm") or []:
|
|
87
|
+
if c and c[0]:
|
|
88
|
+
events.append(ChangeEvent("COMMENT", c[0], c[1], kind_of(c[1], c[3] if len(c) > 3 else None), None,
|
|
89
|
+
c[2] if len(c) > 2 else None, bool(c[4]) if len(c) > 4 else False))
|
|
90
|
+
events.sort(key=lambda e: e.at)
|
|
91
|
+
return cls(entity_id("PullRequest", repo, rec["n"]), repo, rec["n"], rec.get("ti") or "", rec.get("st") or "OPEN",
|
|
92
|
+
bool(rec.get("draft")), rec.get("au"), kind_of(rec.get("au"), rec.get("at")), rec.get("as"), rec.get("cr"),
|
|
93
|
+
ready_at(rec), rec.get("mg"), rec.get("cl"), rec.get("up"), rec.get("add"), rec.get("del"), rec.get("files"),
|
|
94
|
+
rec.get("base"), rec.get("head"), rec.get("ho"), rec.get("lc"), list(rec.get("paths") or []),
|
|
95
|
+
list(rec.get("lb") or []), list(rec.get("rq") or []), events, bool(rec.get("iss")))
|
|
96
|
+
|
|
97
|
+
def to_devlake(self) -> dict:
|
|
98
|
+
# DevLake's pull_requests columns, so its dashboards' SQL runs on an export of ours.
|
|
99
|
+
return {"id": self.id, "pull_request_key": self.number, "title": self.title, "status": self.state,
|
|
100
|
+
"author_name": self.author, "author_id": entity_id("Account", self.repo, self.author or "") if self.author else None,
|
|
101
|
+
"created_date": self.created_at, "merged_date": self.merged_at, "closed_date": self.closed_at,
|
|
102
|
+
"additions": self.additions, "deletions": self.deletions, "is_draft": self.draft,
|
|
103
|
+
"base_ref": self.base_ref, "head_ref": self.head_ref,
|
|
104
|
+
"url": f"https://github.com/{self.repo}/pull/{self.number}"}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class Step:
|
|
109
|
+
number: int | None
|
|
110
|
+
name: str | None
|
|
111
|
+
result: str
|
|
112
|
+
started_at: str | None
|
|
113
|
+
finished_at: str | None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class Task:
|
|
118
|
+
"""DevLake's cicd_tasks: one job of a run attempt."""
|
|
119
|
+
id: str
|
|
120
|
+
name: str | None
|
|
121
|
+
result: str
|
|
122
|
+
created_at: str | None
|
|
123
|
+
started_at: str | None
|
|
124
|
+
finished_at: str | None
|
|
125
|
+
labels: list[str] = field(default_factory=list)
|
|
126
|
+
steps: list[Step] = field(default_factory=list)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def duration_s(self) -> float | None:
|
|
130
|
+
return secs(self.started_at, self.finished_at)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class PipelineRun:
|
|
135
|
+
"""DevLake's cicd_pipelines: one attempt of a workflow run."""
|
|
136
|
+
id: str
|
|
137
|
+
repo: str
|
|
138
|
+
run_id: int
|
|
139
|
+
attempt: int
|
|
140
|
+
name: str | None
|
|
141
|
+
path: str | None
|
|
142
|
+
trigger: str | None
|
|
143
|
+
branch: str | None
|
|
144
|
+
sha: str | None
|
|
145
|
+
tree: str | None
|
|
146
|
+
change_numbers: list[int]
|
|
147
|
+
head_owner: str | None
|
|
148
|
+
result: str
|
|
149
|
+
created_at: str | None
|
|
150
|
+
started_at: str | None
|
|
151
|
+
updated_at: str | None
|
|
152
|
+
tasks: list[Task] = field(default_factory=list)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def from_ci(cls, run: dict, jobs: list[dict], repo: str) -> "PipelineRun":
|
|
156
|
+
key = f"{run['id']}:{run.get('att', 1)}"
|
|
157
|
+
tasks = [Task(entity_id("CicdTask", repo, f"{key}:{j['id']}"), j.get("n"), cicd_result(j.get("c")), j.get("cr"),
|
|
158
|
+
j.get("st"), j.get("en"), list(j.get("lb") or []),
|
|
159
|
+
[Step(s[0], s[1], cicd_result(s[2]), *(list(s[3:5]) + [None, None])[:2]) for s in j.get("steps") or []])
|
|
160
|
+
for j in jobs if j.get("run") == run["id"] and j.get("att", 1) == run.get("att", 1)]
|
|
161
|
+
return cls(entity_id("CicdPipeline", repo, key), repo, run["id"], run.get("att", 1), run.get("wfn"), run.get("path"),
|
|
162
|
+
run.get("ev"), run.get("br"), run.get("sha"), run.get("tree"), list(run.get("pr") or []), run.get("ho"),
|
|
163
|
+
cicd_result(run.get("c")), run.get("cr"), run.get("st"), run.get("up"), tasks)
|
|
164
|
+
|
|
165
|
+
def to_devlake(self, scope: str) -> dict:
|
|
166
|
+
"""DevLake's cicd_pipelines row, its cicd_tasks rows (this attempt's jobs) and its cicd_pipeline_commits row."""
|
|
167
|
+
whole = lambda s: None if s is None else int(round(s)) # noqa: E731
|
|
168
|
+
url = f"https://github.com/{self.repo}/actions/runs/{self.run_id}" + (f"/attempts/{self.attempt}" if self.attempt > 1 else "")
|
|
169
|
+
pipeline = {"id": self.id, "name": self.name, "result": self.result, "status": "DONE",
|
|
170
|
+
"original_status": "completed", "original_result": ORIGINAL.get(self.result, self.result.lower()),
|
|
171
|
+
"duration_sec": whole(secs(self.started_at, self.updated_at)),
|
|
172
|
+
"queued_duration_sec": whole(secs(self.created_at, self.started_at)), "created_date": self.created_at,
|
|
173
|
+
"queued_date": self.created_at, "started_date": self.started_at, "finished_date": self.updated_at,
|
|
174
|
+
"environment": None, "type": None, "cicd_scope_id": scope, "display_title": self.name, "url": url}
|
|
175
|
+
tasks = [{"id": t.id, "name": t.name, "pipeline_id": self.id, "result": t.result, "status": "DONE",
|
|
176
|
+
"original_status": "completed", "original_result": ORIGINAL.get(t.result, t.result.lower()), "type": None,
|
|
177
|
+
"environment": None, "duration_sec": whole(t.duration_s), "queued_duration_sec": whole(secs(t.created_at, t.started_at)),
|
|
178
|
+
"started_date": t.started_at, "finished_date": t.finished_at, "created_date": t.created_at,
|
|
179
|
+
"queued_date": t.created_at, "cicd_scope_id": scope} for t in self.tasks]
|
|
180
|
+
commit = {"pipeline_id": self.id, "commit_sha": self.sha, "branch": self.branch, "repo_id": scope,
|
|
181
|
+
"repo_url": f"https://github.com/{self.repo}"}
|
|
182
|
+
return {"cicd_pipelines": pipeline, "cicd_tasks": tasks, "cicd_pipeline_commits": [commit] if self.sha else []}
|
|
183
|
+
|
|
184
|
+
def to_otel(self) -> dict:
|
|
185
|
+
# OpenTelemetry's CI/CD and VCS attribute names (semantic conventions, release candidate).
|
|
186
|
+
return {"cicd.pipeline.name": self.name, "cicd.pipeline.run.id": self.run_id, "cicd.pipeline.run.attempt": self.attempt,
|
|
187
|
+
"cicd.pipeline.result": self.result, "cicd.pipeline.run.state": "finished",
|
|
188
|
+
"vcs.repository.name": self.repo, "vcs.ref.head.name": self.branch, "vcs.ref.head.revision": self.sha,
|
|
189
|
+
"vcs.change.id": self.change_numbers[0] if self.change_numbers else None}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass
|
|
193
|
+
class TestResult:
|
|
194
|
+
"""One test's outcome in one run attempt, with CTRF's field names in to_ctrf()."""
|
|
195
|
+
test_id: str
|
|
196
|
+
status: str # PASSED, FAILED, FLAKY, SKIPPED
|
|
197
|
+
repo: str
|
|
198
|
+
run_id: int
|
|
199
|
+
attempt: int
|
|
200
|
+
artifact: int | None = None
|
|
201
|
+
suite: str | None = None
|
|
202
|
+
fingerprint: str | None = None
|
|
203
|
+
message: str | None = None
|
|
204
|
+
duration_ms: int | None = None
|
|
205
|
+
|
|
206
|
+
@classmethod
|
|
207
|
+
def from_report(cls, rec: dict, repo: str) -> list["TestResult"]:
|
|
208
|
+
run, att, art, suite = rec.get("run"), rec.get("att", 1), rec.get("art"), rec.get("name")
|
|
209
|
+
slow = {tid: ms for tid, ms in rec.get("slow") or []}
|
|
210
|
+
out = [cls(tid, "FAILED", repo, run, att, art, suite, fp, msg, slow.get(tid)) for tid, fp, msg in rec.get("failed") or []]
|
|
211
|
+
out += [cls(tid, "FLAKY", repo, run, att, art, suite, None, None, slow.get(tid)) for tid in rec.get("flaky") or []]
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
def to_ctrf(self) -> dict:
|
|
215
|
+
status = {"FAILED": "failed", "FLAKY": "passed", "SKIPPED": "skipped"}.get(self.status, "passed")
|
|
216
|
+
out = {"name": self.test_id, "status": status, "duration": self.duration_ms or 0, "flaky": self.status == "FLAKY",
|
|
217
|
+
"suite": self.suite}
|
|
218
|
+
if self.message:
|
|
219
|
+
out["message"] = self.message
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass
|
|
224
|
+
class Issue:
|
|
225
|
+
"""DevLake's issues: type REQUIREMENT, BUG or INCIDENT; status TODO or DONE."""
|
|
226
|
+
id: str
|
|
227
|
+
repo: str
|
|
228
|
+
number: int
|
|
229
|
+
type: str
|
|
230
|
+
status: str
|
|
231
|
+
created_at: str | None
|
|
232
|
+
closed_at: str | None
|
|
233
|
+
author: str | None
|
|
234
|
+
author_kind: str
|
|
235
|
+
labels: list[str] = field(default_factory=list)
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def from_community(cls, rec: dict, repo: str, incident_labels=INCIDENT_LABELS, bug_labels=BUG_LABELS) -> "Issue":
|
|
239
|
+
labels = [str(x).lower() for x in rec.get("lb") or []]
|
|
240
|
+
kind = "INCIDENT" if any(x in labels for x in incident_labels) else "BUG" if any(x in labels for x in bug_labels) \
|
|
241
|
+
else "REQUIREMENT"
|
|
242
|
+
return cls(entity_id("Issue", repo, rec["n"]), repo, rec["n"], kind, "DONE" if rec.get("cl") else "TODO", rec.get("cr"),
|
|
243
|
+
rec.get("cl"), rec.get("au"), kind_of(rec.get("au"), rec.get("at")), labels)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass
|
|
247
|
+
class Release:
|
|
248
|
+
id: str
|
|
249
|
+
repo: str
|
|
250
|
+
tag: str
|
|
251
|
+
published_at: str | None
|
|
252
|
+
sha: str | None = None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass
|
|
256
|
+
class Deployment:
|
|
257
|
+
"""DevLake's cicd_deployment_commits: a successful production deployment of one commit."""
|
|
258
|
+
id: str
|
|
259
|
+
repo: str
|
|
260
|
+
sha: str | None
|
|
261
|
+
finished_at: str | None
|
|
262
|
+
source: str # workflow, api, release
|
|
263
|
+
environment: str = "PRODUCTION"
|
|
264
|
+
result: str = "SUCCESS"
|
|
265
|
+
name: str | None = None
|
|
266
|
+
|
|
267
|
+
@classmethod
|
|
268
|
+
def new(cls, repo: str, key: str, sha: str | None, finished_at: str | None, source: str, name: str | None = None) -> "Deployment":
|
|
269
|
+
return cls(entity_id("CicdDeployment", repo, key), repo, sha, finished_at, source, name=name)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass
|
|
273
|
+
class Incident:
|
|
274
|
+
"""An incident (issue labelled incident, a revert or a hotfix PR) linked to the deployment before it, or to none."""
|
|
275
|
+
id: str
|
|
276
|
+
repo: str
|
|
277
|
+
opened_at: str | None
|
|
278
|
+
resolved_at: str | None
|
|
279
|
+
source: str # issue, revert, hotfix
|
|
280
|
+
deployment_id: str | None = None
|
|
281
|
+
|
|
282
|
+
@classmethod
|
|
283
|
+
def new(cls, repo: str, key: str, opened_at: str | None, resolved_at: str | None, source: str,
|
|
284
|
+
deployment_id: str | None = None) -> "Incident":
|
|
285
|
+
return cls(entity_id("Incident", repo, key), repo, opened_at, resolved_at, source, deployment_id)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""`stackvitals project NAME owner/a owner/b ...`: one project's DORA across several repos, as an Apache DevLake project
|
|
2
|
+
counts it (its own SQL on the repos' tables pooled), next to each repo's own; no DevLake, MySQL or Grafana running."""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from mergescout.github.graphql import GraphQLError
|
|
12
|
+
from stackvitals.ci import cli as ci_cli
|
|
13
|
+
from stackvitals.ci.api import APIError, OutOfBudget
|
|
14
|
+
from stackvitals.core import export, services
|
|
15
|
+
|
|
16
|
+
PANELS = ("deployment_frequency", "lead_time", "change_failure_rate", "recovery")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def project_dora(name: str, tables_by_repo: dict, start: datetime, end: datetime, report: str = "2023") -> dict:
|
|
20
|
+
"""{"project": DevLake's four panels on the pooled tables, "repos": {repo: its own four}}."""
|
|
21
|
+
def four(tables: dict, project: str) -> dict:
|
|
22
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
23
|
+
export.write(tables, tmp)
|
|
24
|
+
got = export.panels(tmp, start, end, report, project)
|
|
25
|
+
return {k: got.get(k) for k in PANELS}
|
|
26
|
+
return {"project": four(export.pool(tables_by_repo, name), name),
|
|
27
|
+
"repos": {repo: four(t, repo) for repo, t in tables_by_repo.items()}}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(argv: list[str] | None = None, tok: str | None = None) -> int:
|
|
31
|
+
ap = argparse.ArgumentParser(prog="stackvitals project", description="DORA for one project across several repos.")
|
|
32
|
+
ap.add_argument("name", help="the project's name")
|
|
33
|
+
ap.add_argument("repos", nargs="+", help="owner/name of each repo in it")
|
|
34
|
+
ap.add_argument("--days", type=int, default=90)
|
|
35
|
+
ap.add_argument("--json", action="store_true")
|
|
36
|
+
a = ap.parse_args(argv)
|
|
37
|
+
tok = tok or ci_cli.token()
|
|
38
|
+
if not tok:
|
|
39
|
+
print("A GitHub token is needed. Set GITHUB_TOKEN, or run `gh auth login`.", file=sys.stderr)
|
|
40
|
+
return 1
|
|
41
|
+
now = datetime.now(timezone.utc)
|
|
42
|
+
days = max(1, min(a.days, 90))
|
|
43
|
+
tables, report = {}, "2023"
|
|
44
|
+
for repo in a.repos:
|
|
45
|
+
try:
|
|
46
|
+
flow, info, ci, issues, cfg = services.delivery_inputs(repo, days, tok, ci_cli.home(), now=now,
|
|
47
|
+
log=lambda line: print(line, file=sys.stderr, flush=True))
|
|
48
|
+
except (APIError, OutOfBudget, GraphQLError, httpx.HTTPError) as exc:
|
|
49
|
+
print(f"Could not read {repo}: {exc}", file=sys.stderr)
|
|
50
|
+
return 1
|
|
51
|
+
report = cfg["delivery"]["dora_report"]
|
|
52
|
+
tables[repo] = export.devlake_tables(repo, flow, info, ci, issues, cfg)
|
|
53
|
+
try:
|
|
54
|
+
out = project_dora(a.name, tables, now - timedelta(days=days), now, report)
|
|
55
|
+
except ImportError:
|
|
56
|
+
print("This needs DuckDB: pip install 'stackvitals[sql]'.", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
if a.json:
|
|
59
|
+
print(json.dumps({"name": a.name, "days": days, **out}, indent=1, default=str))
|
|
60
|
+
return 0
|
|
61
|
+
print(f"{a.name}: {len(a.repos)} repos, last {days} days, DORA as Apache DevLake counts a project:")
|
|
62
|
+
for k in PANELS:
|
|
63
|
+
print(f" {k.replace('_', ' '):24} {out['project'][k]}")
|
|
64
|
+
for repo, p in out["repos"].items():
|
|
65
|
+
print(f" {repo}: " + "; ".join(f"{k.replace('_', ' ')} {p[k]}" for k in PANELS))
|
|
66
|
+
return 0
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Agents propose, people submit: the same prefilled request issue the app opens, for a settings change and/or a
|
|
2
|
+
`/stackvitals` command, checked first with the Action's own parsers so a bad value never reaches GitHub. Nothing is sent:
|
|
3
|
+
a person opens the link and presses Submit, and the workflow applies it if they have the access."""
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from datetime import date, datetime, timezone
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from stackvitals.core import decisions as dec
|
|
12
|
+
from stackvitals.core.tomledit import EditError, apply
|
|
13
|
+
|
|
14
|
+
URL_MAX = 7500
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProposalError(ValueError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def body(changes: list[dict], settings: dict, note: str = "") -> str:
|
|
22
|
+
"""The issue text, the same as the app's write.js builds."""
|
|
23
|
+
block = json.dumps({"version": 1, "changes": changes, "settings": settings}, separators=(",", ":"))
|
|
24
|
+
return (f"{note + chr(10) + chr(10) if note else ''}Sent from the StackVitals app. The StackVitals workflow applies this when "
|
|
25
|
+
f"someone with access opens it, answers here and closes it.\n\n```stackvitals\n{block}\n```\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def proposal(repo: str, settings: dict | None = None, command: str | None = None, note: str = "", today: date | None = None) -> dict:
|
|
29
|
+
"""{url, title, note}: a link to a prefilled request issue; ProposalError when the change can't be applied."""
|
|
30
|
+
today = today or datetime.now(timezone.utc).date()
|
|
31
|
+
settings = settings or {}
|
|
32
|
+
try:
|
|
33
|
+
decided = dec.parse_command(command or "", today) if command else []
|
|
34
|
+
except dec.CommandError as exc:
|
|
35
|
+
raise ProposalError(str(exc)) from None
|
|
36
|
+
if command and not decided:
|
|
37
|
+
raise ProposalError("A command starts with /stackvitals, for example: /stackvitals mute test:<id> for 7d because <reason>.")
|
|
38
|
+
if not decided and not settings:
|
|
39
|
+
raise ProposalError("Nothing to propose: give settings to change and/or a /stackvitals command.")
|
|
40
|
+
if settings:
|
|
41
|
+
try:
|
|
42
|
+
apply("", settings) # every key must exist and every value be one the parser accepts
|
|
43
|
+
except EditError as exc:
|
|
44
|
+
raise ProposalError(str(exc)) from None
|
|
45
|
+
changes = [asdict(d) for d in decided]
|
|
46
|
+
what = ", ".join([*(f"{d.verb} {d.kind} {d.id}" for d in decided), *settings])
|
|
47
|
+
title = f"StackVitals: {what}"[:120]
|
|
48
|
+
url = f"https://github.com/{repo}/issues/new?title={quote(title)}&body={quote(body(changes, settings, note))}"
|
|
49
|
+
if len(url) > URL_MAX:
|
|
50
|
+
raise ProposalError("This change is too long for one link; split it into smaller ones.")
|
|
51
|
+
need = "write" if settings else "triage or write"
|
|
52
|
+
return {"url": url, "title": title,
|
|
53
|
+
"note": f"Open the link and press Submit; the StackVitals workflow applies it when someone with {need} access opened it."}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _value(text: str):
|
|
57
|
+
try:
|
|
58
|
+
return json.loads(text)
|
|
59
|
+
except ValueError:
|
|
60
|
+
return text # a plain word, like a regex or a day name
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv: list[str] | None = None) -> int:
|
|
64
|
+
parser = argparse.ArgumentParser(prog="stackvitals propose",
|
|
65
|
+
description="Print a link that proposes a settings change or a decision; a person submits it.")
|
|
66
|
+
parser.add_argument("repo", help="owner/name")
|
|
67
|
+
parser.add_argument("--set", action="append", default=[], metavar="KEY=VALUE",
|
|
68
|
+
help="a setting, e.g. alerts.pickup_p75_hours=6 or automation.issues=true (JSON values)")
|
|
69
|
+
parser.add_argument("--decide", help='a command, e.g. "/stackvitals mute test:<id> for 7d because <reason>"')
|
|
70
|
+
parser.add_argument("--note", default="", help="a line of context for the person who submits it")
|
|
71
|
+
args = parser.parse_args(argv)
|
|
72
|
+
settings = {}
|
|
73
|
+
for item in args.set:
|
|
74
|
+
key, sep, value = item.partition("=")
|
|
75
|
+
if not sep:
|
|
76
|
+
print(f"--set needs KEY=VALUE, not {item!r}", file=sys.stderr)
|
|
77
|
+
return 2
|
|
78
|
+
settings[key.strip()] = _value(value.strip())
|
|
79
|
+
try:
|
|
80
|
+
out = proposal(args.repo, settings, args.decide, args.note)
|
|
81
|
+
except ProposalError as exc:
|
|
82
|
+
print(f"Not proposed: {exc}", file=sys.stderr)
|
|
83
|
+
return 1
|
|
84
|
+
print(out["note"])
|
|
85
|
+
print(out["url"])
|
|
86
|
+
return 0
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""A weekly developer pulse in the digest issue (SPACE's satisfaction and DX Core 4's experience, the GitHub-native way):
|
|
2
|
+
three questions posted as comments that people answer with a reaction, counted the next week. StackVitals keeps and
|
|
3
|
+
shows only the counts; GitHub itself shows who reacted to anyone who can see the issue. On a public repo only the
|
|
4
|
+
team's own people (insiders) are counted."""
|
|
5
|
+
|
|
6
|
+
QUESTIONS = (("flow", "Could you get your work done this week without waiting on others?"),
|
|
7
|
+
("friction", "Did CI and reviews stay out of your way this week?"),
|
|
8
|
+
("satisfaction", "Are you happy with how the team shipped this week?"))
|
|
9
|
+
ANSWERS = {"+1": "yes", "-1": "no", "confused": "mixed"}
|
|
10
|
+
NAMES = {"flow": "Flow", "friction": "Low friction", "satisfaction": "Satisfaction"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _marker(week: str, key: str) -> str:
|
|
14
|
+
return f"<!-- stackvitals:pulse {week} {key} -->"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ask(api, issue: int, week: str) -> None:
|
|
18
|
+
for key, question in QUESTIONS:
|
|
19
|
+
api.send("POST", f"/repos/{api.repo}/issues/{issue}/comments",
|
|
20
|
+
{"body": f"**Pulse:** {question}\n\nReact 👍 yes, 👎 no, 😕 mixed. StackVitals reads only the counts (reactions themselves are visible on GitHub).\n{_marker(week, key)}"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def tally(api, issue: int, week: str, since: str | None = None, keep=None) -> dict:
|
|
24
|
+
"""Reaction counts on that week's three questions (`keep(login)` picks whose count); a question not found counts
|
|
25
|
+
nothing. Up to 300 reactions a question."""
|
|
26
|
+
comments = api._get(f"/repos/{api.repo}/issues/{issue}/comments", {"per_page": 100, **({"since": since} if since else {})})
|
|
27
|
+
out = {key: {"yes": 0, "no": 0, "mixed": 0} for key, _q in QUESTIONS}
|
|
28
|
+
for c in comments or []:
|
|
29
|
+
key = next((k for k, _q in QUESTIONS if _marker(week, k) in (c.get("body") or "")), None)
|
|
30
|
+
if not key:
|
|
31
|
+
continue
|
|
32
|
+
for page in (1, 2, 3):
|
|
33
|
+
got = api._get(f"/repos/{api.repo}/issues/comments/{c['id']}/reactions", {"per_page": 100, "page": page}) or []
|
|
34
|
+
for r in got:
|
|
35
|
+
if r.get("content") in ANSWERS and (keep is None or keep((r.get("user") or {}).get("login"))):
|
|
36
|
+
out[key][ANSWERS[r["content"]]] += 1
|
|
37
|
+
if len(got) < 100:
|
|
38
|
+
break
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def section(counts: dict) -> str | None:
|
|
43
|
+
"""The digest's pulse lines, or None when nobody answered."""
|
|
44
|
+
if not any(sum(v.values()) for v in counts.values()):
|
|
45
|
+
return None
|
|
46
|
+
parts = [f"{NAMES[k]} {v['yes']} yes, {v['no']} no" + (f", {v['mixed']} mixed" if v["mixed"] else "")
|
|
47
|
+
for k, v in counts.items() if sum(v.values())]
|
|
48
|
+
return "**Team pulse last week** (reactions, counts only): " + "; ".join(parts) + "."
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
KEEP_WEEKS = 52
|
|
52
|
+
MIN_ANSWERS = 5 # fewer answers to a question in a week and no share is shown: too few to read, too easy to trace
|
|
53
|
+
# The number each question sits beside in the app: waiting on others / CI and reviews in the way / how the team shipped.
|
|
54
|
+
BESIDE = {"flow": "pickup", "friction": "first_try", "satisfaction": "deployments"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def record(root, week: str, counts: dict) -> None:
|
|
58
|
+
"""Keep one week's counts (and nothing about who answered) in docs/pulse.json, a year at most."""
|
|
59
|
+
import json
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
|
|
62
|
+
from stackvitals.core.store import published, write_if_changed
|
|
63
|
+
path = Path(root) / "docs" / "pulse.json"
|
|
64
|
+
try:
|
|
65
|
+
weeks = json.loads(path.read_text(encoding="utf-8")).get("weeks") or []
|
|
66
|
+
except (OSError, ValueError):
|
|
67
|
+
weeks = []
|
|
68
|
+
clean = {k: {a: int((counts.get(k) or {}).get(a) or 0) for a in ("yes", "no", "mixed")} for k, _q in QUESTIONS}
|
|
69
|
+
weeks = [w for w in weeks if w.get("week") != week] + [{"week": week, **clean}]
|
|
70
|
+
weeks = sorted(weeks, key=lambda w: w["week"])[-KEEP_WEEKS:]
|
|
71
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
write_if_changed(path, published({"weeks": weeks, "beside": BESIDE, "min_answers": MIN_ANSWERS}))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def trend(weeks: list[dict]) -> dict:
|
|
76
|
+
"""{question: [{"week", "answers", "yes_share"}]}; the share is None for a week with fewer than 5 answers."""
|
|
77
|
+
out = {}
|
|
78
|
+
for key, _q in QUESTIONS:
|
|
79
|
+
rows = []
|
|
80
|
+
for w in weeks:
|
|
81
|
+
c = w.get(key) or {}
|
|
82
|
+
n = sum(int(c.get(a) or 0) for a in ("yes", "no", "mixed"))
|
|
83
|
+
rows.append({"week": w["week"], "answers": n, "yes_share": round(c.get("yes", 0) / n, 4) if n >= MIN_ANSWERS else None})
|
|
84
|
+
out[key] = rows
|
|
85
|
+
return out
|