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,342 @@
|
|
|
1
|
+
"""Exports in other tools' shapes. DevLake: its domain tables (pull_requests, project_pr_metrics, cicd_deployment_commits,
|
|
2
|
+
incidents, issues and the project links) as JSON lines, with DevLake's own DORA dashboard SQL ported to DuckDB, so its
|
|
3
|
+
five DORA panels can be read off our data with no DevLake, MySQL or Grafana running."""
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from stackvitals.connectors import tracker as tracked
|
|
12
|
+
from stackvitals.core import delivery
|
|
13
|
+
from stackvitals.core.delivery import _minutes_down, _minutes_up
|
|
14
|
+
from stackvitals.core.model import ChangeRequest, Issue, PipelineRun, cicd_result, entity_id
|
|
15
|
+
|
|
16
|
+
# Column types, so DuckDB reads an empty table too.
|
|
17
|
+
COLUMNS = {
|
|
18
|
+
"project_mapping": {"project_name": "VARCHAR", "table": "VARCHAR", "row_id": "VARCHAR"},
|
|
19
|
+
"pull_requests": {"id": "VARCHAR", "base_repo_id": "VARCHAR", "pull_request_key": "BIGINT", "title": "VARCHAR",
|
|
20
|
+
"status": "VARCHAR", "author_name": "VARCHAR", "author_id": "VARCHAR", "created_date": "TIMESTAMP",
|
|
21
|
+
"merged_date": "TIMESTAMP", "closed_date": "TIMESTAMP", "merge_commit_sha": "VARCHAR",
|
|
22
|
+
"additions": "BIGINT", "deletions": "BIGINT", "is_draft": "BOOLEAN", "base_ref": "VARCHAR",
|
|
23
|
+
"head_ref": "VARCHAR", "url": "VARCHAR"},
|
|
24
|
+
"project_pr_metrics": {"id": "VARCHAR", "project_name": "VARCHAR", "first_commit_authored_date": "TIMESTAMP",
|
|
25
|
+
"pr_coding_time": "BIGINT", "pr_pickup_time": "BIGINT", "pr_review_time": "BIGINT",
|
|
26
|
+
"pr_deploy_time": "BIGINT", "pr_cycle_time": "BIGINT", "deployment_commit_id": "VARCHAR",
|
|
27
|
+
"first_comment_date": "TIMESTAMP", "pr_created_date": "TIMESTAMP", "pr_merged_date": "TIMESTAMP",
|
|
28
|
+
"pr_deployed_date": "TIMESTAMP"},
|
|
29
|
+
"cicd_deployment_commits": {"id": "VARCHAR", "cicd_deployment_id": "VARCHAR", "cicd_scope_id": "VARCHAR", "name": "VARCHAR",
|
|
30
|
+
"result": "VARCHAR", "status": "VARCHAR", "environment": "VARCHAR", "commit_sha": "VARCHAR",
|
|
31
|
+
"finished_date": "TIMESTAMP", "source": "VARCHAR"},
|
|
32
|
+
"incidents": {"id": "VARCHAR", "incident_key": "VARCHAR", "title": "VARCHAR", "status": "VARCHAR", "source": "VARCHAR",
|
|
33
|
+
"created_date": "TIMESTAMP", "resolution_date": "TIMESTAMP", "lead_time_minutes": "BIGINT",
|
|
34
|
+
"scope_id": "VARCHAR", "table": "VARCHAR", "url": "VARCHAR"},
|
|
35
|
+
"project_incident_deployment_relationships": {"id": "VARCHAR", "project_name": "VARCHAR", "deployment_id": "VARCHAR"},
|
|
36
|
+
"issues": {"id": "VARCHAR", "issue_key": "VARCHAR", "title": "VARCHAR", "type": "VARCHAR", "original_type": "VARCHAR",
|
|
37
|
+
"status": "VARCHAR", "original_status": "VARCHAR", "story_point": "DOUBLE", "priority": "VARCHAR",
|
|
38
|
+
"created_date": "TIMESTAMP", "updated_date": "TIMESTAMP", "resolution_date": "TIMESTAMP",
|
|
39
|
+
"lead_time_minutes": "BIGINT", "creator_name": "VARCHAR", "url": "VARCHAR"},
|
|
40
|
+
"issue_changelogs": {"id": "VARCHAR", "issue_id": "VARCHAR", "field_name": "VARCHAR", "original_from_value": "VARCHAR",
|
|
41
|
+
"original_to_value": "VARCHAR", "from_value": "VARCHAR", "to_value": "VARCHAR", "created_date": "TIMESTAMP"},
|
|
42
|
+
"sprints": {"id": "VARCHAR", "name": "VARCHAR", "status": "VARCHAR", "started_date": "TIMESTAMP", "ended_date": "TIMESTAMP",
|
|
43
|
+
"completed_date": "TIMESTAMP"},
|
|
44
|
+
"sprint_issues": {"sprint_id": "VARCHAR", "issue_id": "VARCHAR"},
|
|
45
|
+
"pull_request_issues": {"pull_request_id": "VARCHAR", "issue_id": "VARCHAR", "pull_request_key": "BIGINT", "issue_key": "VARCHAR"},
|
|
46
|
+
"cicd_pipelines": {"id": "VARCHAR", "name": "VARCHAR", "result": "VARCHAR", "status": "VARCHAR", "original_status": "VARCHAR",
|
|
47
|
+
"original_result": "VARCHAR", "duration_sec": "BIGINT", "queued_duration_sec": "BIGINT",
|
|
48
|
+
"created_date": "TIMESTAMP", "queued_date": "TIMESTAMP", "started_date": "TIMESTAMP",
|
|
49
|
+
"finished_date": "TIMESTAMP", "environment": "VARCHAR", "type": "VARCHAR", "cicd_scope_id": "VARCHAR",
|
|
50
|
+
"display_title": "VARCHAR", "url": "VARCHAR"},
|
|
51
|
+
"cicd_tasks": {"id": "VARCHAR", "name": "VARCHAR", "pipeline_id": "VARCHAR", "result": "VARCHAR", "status": "VARCHAR",
|
|
52
|
+
"original_status": "VARCHAR", "original_result": "VARCHAR", "type": "VARCHAR", "environment": "VARCHAR",
|
|
53
|
+
"duration_sec": "BIGINT", "queued_duration_sec": "BIGINT", "started_date": "TIMESTAMP",
|
|
54
|
+
"finished_date": "TIMESTAMP", "created_date": "TIMESTAMP", "queued_date": "TIMESTAMP", "cicd_scope_id": "VARCHAR"},
|
|
55
|
+
"cicd_pipeline_commits": {"pipeline_id": "VARCHAR", "commit_sha": "VARCHAR", "branch": "VARCHAR", "repo_id": "VARCHAR",
|
|
56
|
+
"repo_url": "VARCHAR"},
|
|
57
|
+
}
|
|
58
|
+
PANELS = ("deployment_frequency", "lead_time", "change_failure_rate", "recovery", "monthly", "pr_cycle_time")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def devlake_tables(repo: str, flow: dict, info: dict, ci: dict, issues: list[dict], cfg: dict,
|
|
62
|
+
tracker: dict | None = None, ci_since: str | None = None) -> dict[str, list[dict]]:
|
|
63
|
+
"""Our records in DevLake's domain layer: ids, names and enums as DevLake writes them, its PR metrics in whole minutes;
|
|
64
|
+
with a connected tracker, its issues, status changes, sprints and the PRs that name them too."""
|
|
65
|
+
flow = delivery.apply_identity(flow, cfg.get("identity"))
|
|
66
|
+
deps, _source, rows = delivery.shipped(flow, info, ci, cfg)
|
|
67
|
+
scope = entity_id("Repo", repo, repo)
|
|
68
|
+
deployment_row = {d["id"]: entity_id("CicdDeploymentCommit", repo, d["id"]) for d in deps}
|
|
69
|
+
out = {name: [] for name in COLUMNS}
|
|
70
|
+
out["project_mapping"] = [{"project_name": repo, "table": t, "row_id": scope} for t in ("repos", "cicd_scopes")]
|
|
71
|
+
out["cicd_deployment_commits"] = [
|
|
72
|
+
{"id": deployment_row[d["id"]], "cicd_deployment_id": entity_id("CicdDeployment", repo, d["id"]), "cicd_scope_id": scope,
|
|
73
|
+
"name": d.get("name"), "result": "SUCCESS", "status": "DONE", "environment": "PRODUCTION", "commit_sha": d.get("sha"),
|
|
74
|
+
"finished_date": d["finished"], "source": d.get("source")} for d in deps]
|
|
75
|
+
closed, opened = flow.get("closed") or [], flow.get("open") or []
|
|
76
|
+
for rec in closed + opened:
|
|
77
|
+
row = ChangeRequest.from_flow(rec, repo).to_devlake()
|
|
78
|
+
out["pull_requests"].append({**{k: row.get(k) for k in COLUMNS["pull_requests"]}, "base_repo_id": scope,
|
|
79
|
+
"merge_commit_sha": rec.get("mc")})
|
|
80
|
+
for p, ph, dep in rows:
|
|
81
|
+
first = ph["first_activity"]
|
|
82
|
+
out["project_pr_metrics"].append({
|
|
83
|
+
"id": entity_id("PullRequest", repo, p["n"]), "project_name": repo, "first_commit_authored_date": p.get("fa"),
|
|
84
|
+
"pr_coding_time": _minutes_up(p.get("fa"), p["cr"]), "pr_pickup_time": _minutes_up(p["cr"], first),
|
|
85
|
+
"pr_review_time": _minutes_up(first, p["mg"]), "pr_deploy_time": _minutes_up(p["mg"], dep["finished"]) if dep else None,
|
|
86
|
+
"pr_cycle_time": ph["devlake_cycle_min"], "deployment_commit_id": deployment_row[dep["id"]] if dep else None,
|
|
87
|
+
"first_comment_date": first, "pr_created_date": p["cr"], "pr_merged_date": p["mg"],
|
|
88
|
+
"pr_deployed_date": dep["finished"] if dep else None})
|
|
89
|
+
for inc in delivery.incidents_for(closed + opened, issues, deps, cfg, tracker)[0]:
|
|
90
|
+
key = entity_id("Incident", repo, f"{inc['source']}:{inc['number']}")
|
|
91
|
+
kind = "issues" if inc["source"] == "issue" else "pull"
|
|
92
|
+
out["incidents"].append({"id": key, "incident_key": str(inc["number"]), "title": None,
|
|
93
|
+
"status": "DONE" if inc["resolved"] else "TODO", "source": inc["source"],
|
|
94
|
+
"created_date": inc["created"], "resolution_date": inc["resolved"],
|
|
95
|
+
"lead_time_minutes": _minutes_down(inc["created"], inc["resolved"]), "scope_id": scope,
|
|
96
|
+
"table": "repos", "url": inc.get("url") or f"https://github.com/{repo}/{kind}/{inc['number']}"})
|
|
97
|
+
if inc["deployment"]:
|
|
98
|
+
out["project_incident_deployment_relationships"].append(
|
|
99
|
+
{"id": key, "project_name": repo, "deployment_id": entity_id("CicdDeployment", repo, inc["deployment"])})
|
|
100
|
+
labels = cfg["delivery"]["incident_labels"]
|
|
101
|
+
for rec in issues or []:
|
|
102
|
+
i = Issue.from_community(rec, repo, incident_labels=[x.lower() for x in labels])
|
|
103
|
+
out["issues"].append({**dict.fromkeys(COLUMNS["issues"]), "id": i.id, "issue_key": str(i.number), "type": i.type,
|
|
104
|
+
"status": i.status, "created_date": i.created_at, "resolution_date": i.closed_at,
|
|
105
|
+
"lead_time_minutes": _minutes_down(i.created_at, i.closed_at), "creator_name": i.author,
|
|
106
|
+
"url": f"https://github.com/{repo}/issues/{i.number}"})
|
|
107
|
+
# Every CI run attempt with its jobs, as DevLake's cicd tables (pipelines, tasks, the commit each ran on).
|
|
108
|
+
jobs = defaultdict(list)
|
|
109
|
+
for j in (ci or {}).get("jobs") or []:
|
|
110
|
+
jobs[(j["run"], j.get("att", 1))].append(j)
|
|
111
|
+
for run in (ci or {}).get("runs") or []:
|
|
112
|
+
if ci_since and (run.get("cr") or "") < ci_since:
|
|
113
|
+
continue # the summary on the data branch keeps recent runs only; `stackvitals export` keeps them all
|
|
114
|
+
rows = PipelineRun.from_ci(run, jobs[(run["id"], run.get("att", 1))], repo).to_devlake(scope)
|
|
115
|
+
out["cicd_pipelines"].append(rows["cicd_pipelines"])
|
|
116
|
+
out["cicd_tasks"] += rows["cicd_tasks"]
|
|
117
|
+
out["cicd_pipeline_commits"] += rows["cicd_pipeline_commits"]
|
|
118
|
+
if tracker:
|
|
119
|
+
_tracker_rows(out, repo, closed + opened, tracker)
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _tracker_rows(out: dict, repo: str, prs: list[dict], tracker: dict) -> None:
|
|
124
|
+
category = {i.get("status"): i.get("cat") for i in tracker["issues"]}
|
|
125
|
+
for i in tracker["issues"]:
|
|
126
|
+
done = i.get("cat") == "DONE"
|
|
127
|
+
out["issues"].append({**dict.fromkeys(COLUMNS["issues"]), "id": i["id"], "issue_key": i["key"], "title": i.get("title"),
|
|
128
|
+
"type": i.get("type"), "original_type": i.get("original_type"), "status": i.get("cat"),
|
|
129
|
+
"original_status": i.get("status"), "story_point": i.get("points"), "priority": i.get("priority"),
|
|
130
|
+
"created_date": i.get("created"), "updated_date": i.get("updated"),
|
|
131
|
+
"resolution_date": i.get("resolved") if done else None,
|
|
132
|
+
"lead_time_minutes": _minutes_down(i.get("created"), i.get("resolved"))
|
|
133
|
+
if done and not i.get("canceled") else None, "url": i.get("url")})
|
|
134
|
+
out["sprint_issues"] += [{"sprint_id": sid, "issue_id": i["id"]} for sid in i.get("sprints") or []]
|
|
135
|
+
for c in tracker["changes"]:
|
|
136
|
+
status = c.get("field") == "status"
|
|
137
|
+
text = (lambda v: v) if status else (lambda v: ",".join(v or []))
|
|
138
|
+
out["issue_changelogs"].append({"id": c["id"], "issue_id": c["issue"], "field_name": "status" if status else "Sprint",
|
|
139
|
+
"original_from_value": text(c.get("from")), "original_to_value": text(c.get("to")),
|
|
140
|
+
"from_value": category.get(c.get("from")) if status else text(c.get("from")),
|
|
141
|
+
"to_value": category.get(c.get("to")) if status else text(c.get("to")),
|
|
142
|
+
"created_date": c.get("at")})
|
|
143
|
+
out["sprints"] = [{"id": s["id"], "name": s.get("name"), "status": s.get("state"), "started_date": s.get("start"),
|
|
144
|
+
"ended_date": s.get("end"), "completed_date": s.get("complete")} for s in tracker["sprints"]]
|
|
145
|
+
ids = {i["key"]: i["id"] for i in tracker["issues"]}
|
|
146
|
+
out["pull_request_issues"] = [{"pull_request_id": entity_id("PullRequest", repo, n), "issue_id": ids[key],
|
|
147
|
+
"pull_request_key": n, "issue_key": key} for n, key in tracked.pr_links(prs, tracker["issues"])]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _sql_text() -> str:
|
|
151
|
+
return resources.files("stackvitals.core").joinpath("devlake_dora.sql").read_text(encoding="utf-8")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _literal(path: Path | str) -> str:
|
|
155
|
+
return str(path).replace("\\", "/").replace("'", "''")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def setup_sql(folder: Path | str | None = None) -> str:
|
|
159
|
+
"""CREATE TABLE statements reading each exported file (relative names when no folder is given)."""
|
|
160
|
+
lines = []
|
|
161
|
+
for name, cols in COLUMNS.items():
|
|
162
|
+
spec = ", ".join(f"'{c}': '{t}'" for c, t in cols.items())
|
|
163
|
+
where = _literal(Path(folder) / f"{name}.jsonl") if folder else f"{name}.jsonl"
|
|
164
|
+
lines.append(f"CREATE OR REPLACE TABLE {name} AS SELECT * FROM read_json('{where}', format='newline_delimited', "
|
|
165
|
+
f"columns={{{spec}}});")
|
|
166
|
+
lines.append("CREATE OR REPLACE TABLE calendar_months AS SELECT strftime(m, '%y/%m') AS month, CAST(m AS date) AS month_timestamp "
|
|
167
|
+
"FROM range(TIMESTAMP '2000-01-01', TIMESTAMP '2100-01-01', INTERVAL 1 MONTH) t(m);")
|
|
168
|
+
return "\n".join(lines)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def panel_sql(start: datetime, end: datetime, report: str, project: str) -> dict[str, str]:
|
|
172
|
+
"""DevLake's five DORA panels with the time range, project and report filled in."""
|
|
173
|
+
fill = {"{{FROM}}": start.strftime("%Y-%m-%d %H:%M:%S"), "{{TO}}": end.strftime("%Y-%m-%d %H:%M:%S"),
|
|
174
|
+
"{{PROJECT}}": project.replace("'", "''"), "{{REPORT}}": "2021" if report == "2021" else "2023"}
|
|
175
|
+
out = {}
|
|
176
|
+
for chunk in _sql_text().split("-- panel: ")[1:]:
|
|
177
|
+
name, body = chunk.split("\n", 1)
|
|
178
|
+
for key, value in fill.items():
|
|
179
|
+
body = body.replace(key, value)
|
|
180
|
+
out[name.strip()] = body.strip()
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def write(tables: dict[str, list[dict]], folder: Path | str, start: datetime | None = None, end: datetime | None = None,
|
|
185
|
+
report: str = "2023", project: str | None = None) -> Path:
|
|
186
|
+
"""One JSON-lines file per table, and dora.sql: run `duckdb < dora.sql` inside the folder to see DevLake's panels."""
|
|
187
|
+
folder = Path(folder)
|
|
188
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
for name, rows in tables.items():
|
|
190
|
+
with open(folder / f"{name}.jsonl", "w", encoding="utf-8", newline="\n") as fh:
|
|
191
|
+
for row in rows:
|
|
192
|
+
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
193
|
+
script = [setup_sql()]
|
|
194
|
+
if start and end and project:
|
|
195
|
+
script += [f"-- {name}\n{sql}" for name, sql in panel_sql(start, end, report, project).items()]
|
|
196
|
+
(folder / "dora.sql").write_text("\n\n".join(script) + "\n", encoding="utf-8")
|
|
197
|
+
return folder
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def pool(tables_by_repo: dict[str, dict[str, list[dict]]], project: str) -> dict[str, list[dict]]:
|
|
201
|
+
"""Several repos' DevLake tables as one DevLake project: rows side by side (their ids already name the repo), and
|
|
202
|
+
every repo's scopes mapped to the project, so DevLake's own SQL gives the project's DORA across them."""
|
|
203
|
+
out = {name: [] for name in COLUMNS}
|
|
204
|
+
for tables in tables_by_repo.values():
|
|
205
|
+
for name, rows in tables.items():
|
|
206
|
+
if name == "project_mapping":
|
|
207
|
+
out[name] += [{**r, "project_name": project} for r in rows]
|
|
208
|
+
else:
|
|
209
|
+
out.setdefault(name, []).extend(rows)
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def panels(folder: Path | str, start: datetime, end: datetime, report: str, project: str) -> dict:
|
|
214
|
+
"""DevLake's DORA panels computed by DuckDB on an export folder: four strings as DevLake shows them, and the
|
|
215
|
+
monthly deployments as (month, count) rows."""
|
|
216
|
+
import duckdb
|
|
217
|
+
con = duckdb.connect()
|
|
218
|
+
for stmt in setup_sql(folder).split(";\n"):
|
|
219
|
+
con.execute(stmt.rstrip(";"))
|
|
220
|
+
out = {}
|
|
221
|
+
for name, sql in panel_sql(start, end, report, project).items():
|
|
222
|
+
rows = con.execute(sql.rstrip(";")).fetchall()
|
|
223
|
+
out[name] = [tuple(r) for r in rows] if name in ("monthly", "pr_cycle_time") else (rows[0][0] if rows else None)
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def ctrf(repo: str, result: dict, start: datetime, end: datetime) -> dict:
|
|
228
|
+
"""CI Health's tests as a CTRF report: each test once, failed when it is broken now, flagged flaky when it flakes,
|
|
229
|
+
with its history over the window under `extra`."""
|
|
230
|
+
tests = []
|
|
231
|
+
for t in result.get("tests") or []:
|
|
232
|
+
test = {"name": t["id"], "status": "failed" if t.get("state") == "broken" else "passed", "duration": 0,
|
|
233
|
+
"suite": t.get("suite"), "flaky": t.get("state") == "flaky",
|
|
234
|
+
"extra": {k: t.get(k) for k in ("runs", "failures", "flake_events", "flake_rate", "state", "since")}}
|
|
235
|
+
if t.get("message"):
|
|
236
|
+
test["message"] = t["message"]
|
|
237
|
+
tests.append(test)
|
|
238
|
+
failed = sum(1 for t in tests if t["status"] == "failed")
|
|
239
|
+
return {"results": {"tool": {"name": "stackvitals"},
|
|
240
|
+
"summary": {"tests": len(tests), "passed": len(tests) - failed, "failed": failed, "skipped": 0,
|
|
241
|
+
"pending": 0, "other": 0, "start": int(start.timestamp() * 1000),
|
|
242
|
+
"stop": int(end.timestamp() * 1000)},
|
|
243
|
+
"tests": tests,
|
|
244
|
+
"environment": {"repositoryName": repo, "repositoryUrl": f"https://github.com/{repo}"}}}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
PG_TYPES = {"VARCHAR": "TEXT", "TIMESTAMP": "TIMESTAMPTZ", "BIGINT": "BIGINT", "BOOLEAN": "BOOLEAN", "DOUBLE": "DOUBLE PRECISION"}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _ns(ts: str | None) -> str:
|
|
251
|
+
return str(int(datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp() * 10 ** 9)) if ts else "0"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _span_id(*parts, n: int = 16) -> str:
|
|
255
|
+
# The same run always gets the same ids, so sending an export twice doesn't make two traces.
|
|
256
|
+
return hashlib.sha256("|".join(str(p) for p in parts).encode()).hexdigest()[:n]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _attrs(values: dict) -> list[dict]:
|
|
260
|
+
return [{"key": k, "value": {"intValue": str(v)} if isinstance(v, int) and not isinstance(v, bool) else {"stringValue": str(v)}}
|
|
261
|
+
for k, v in values.items() if v is not None]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _status(conclusion: str | None) -> dict:
|
|
265
|
+
return {"code": 2 if conclusion in ("failure", "timed_out") else 1 if conclusion == "success" else 0}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def otel_traces(repo: str, data: dict) -> dict:
|
|
269
|
+
"""The CI history as OpenTelemetry traces (OTLP JSON): one trace per run attempt, a span per job and per step, and
|
|
270
|
+
each failed or flaky test as an event on the run, with OpenTelemetry's CI/CD attribute names."""
|
|
271
|
+
jobs, tests = defaultdict(list), defaultdict(list)
|
|
272
|
+
for j in data.get("jobs") or []:
|
|
273
|
+
jobs[(j["run"], j["att"])].append(j)
|
|
274
|
+
for t in data.get("tests") or []:
|
|
275
|
+
tests[(t["run"], t["att"])].append(t)
|
|
276
|
+
spans = []
|
|
277
|
+
for run in sorted(data.get("runs") or [], key=lambda r: (r.get("cr") or "", r["id"], r["att"])):
|
|
278
|
+
key = (run["id"], run["att"])
|
|
279
|
+
trace, root = _span_id(repo, *key, n=32), _span_id(repo, *key, "run")
|
|
280
|
+
attrs = {k: v for k, v in PipelineRun.from_ci(run, jobs[key], repo).to_otel().items() if v is not None}
|
|
281
|
+
events = [{"name": f"test.{kind}", "timeUnixNano": _ns(run.get("up")),
|
|
282
|
+
"attributes": _attrs({"test.case.name": tid, "test.suite.name": rep.get("name"),
|
|
283
|
+
**({"exception.message": msg} if msg else {})})}
|
|
284
|
+
for rep in tests[key] for kind, rows in (("failed", rep.get("failed") or []), ("flaky", rep.get("flaky") or []))
|
|
285
|
+
for tid, msg in ((r[0], r[2] if len(r) > 2 else None) if isinstance(r, list) else (r, None) for r in rows)]
|
|
286
|
+
# A job or step with no times of its own takes its parent's, so no span starts in 1970.
|
|
287
|
+
run_start, run_end = run.get("st") or run.get("cr"), run.get("up") or run.get("st") or run.get("cr")
|
|
288
|
+
spans.append({"traceId": trace, "spanId": root, "name": f"{run.get('wfn') or run.get('path')} #{run['id']}", "kind": 1,
|
|
289
|
+
"startTimeUnixNano": _ns(run_start), "endTimeUnixNano": _ns(run_end),
|
|
290
|
+
"attributes": _attrs(attrs), "status": _status(run.get("c")), "events": events})
|
|
291
|
+
for j in sorted(jobs[key], key=lambda j: (j.get("st") or "", j["id"])):
|
|
292
|
+
job = _span_id(repo, *key, j["id"])
|
|
293
|
+
job_start, job_end = j.get("st") or run_start, j.get("en") or run_end
|
|
294
|
+
spans.append({"traceId": trace, "spanId": job, "parentSpanId": root, "name": j.get("n") or "job", "kind": 1,
|
|
295
|
+
"startTimeUnixNano": _ns(job_start), "endTimeUnixNano": _ns(job_end),
|
|
296
|
+
"attributes": _attrs({"cicd.pipeline.task.name": j.get("n"), "cicd.pipeline.task.run.id": j["id"],
|
|
297
|
+
"cicd.pipeline.task.run.result": cicd_result(j.get("c")).lower(),
|
|
298
|
+
"cicd.worker.labels": ",".join(j.get("lb") or []) or None}),
|
|
299
|
+
"status": _status(j.get("c"))})
|
|
300
|
+
for step in j.get("steps") or []:
|
|
301
|
+
number, name, conclusion = step[0], step[1], step[2]
|
|
302
|
+
spans.append({"traceId": trace, "spanId": _span_id(repo, *key, j["id"], number), "parentSpanId": job,
|
|
303
|
+
"name": name or f"step {number}", "kind": 1,
|
|
304
|
+
"startTimeUnixNano": _ns((step[3] if len(step) > 3 else None) or job_start),
|
|
305
|
+
"endTimeUnixNano": _ns((step[4] if len(step) > 4 else None) or job_end), "attributes": [],
|
|
306
|
+
"status": _status(conclusion)})
|
|
307
|
+
return {"resourceSpans": [{"resource": {"attributes": _attrs({"service.name": "ci", "vcs.repository.name": repo})},
|
|
308
|
+
"scopeSpans": [{"scope": {"name": "stackvitals"}, "spans": spans}]}]}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def postgres_ddl() -> str:
|
|
312
|
+
"""CREATE TABLE statements for DevLake's tables in Postgres (Neon, Supabase, Aiven or your own), from the same columns
|
|
313
|
+
the export writes; `table` is quoted as Postgres needs."""
|
|
314
|
+
out = []
|
|
315
|
+
for name, cols in COLUMNS.items():
|
|
316
|
+
lines = [f' "{c}" {PG_TYPES[t]}' if c == "table" else f" {c} {PG_TYPES[t]}" for c, t in cols.items()]
|
|
317
|
+
if "id" in cols and name != "project_incident_deployment_relationships":
|
|
318
|
+
lines.append(" PRIMARY KEY (id)")
|
|
319
|
+
out.append(f"CREATE TABLE IF NOT EXISTS {name} (\n" + ",\n".join(lines) + "\n);")
|
|
320
|
+
return "\n\n".join(out)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def write_postgres(tables: dict[str, list[dict]], folder: Path | str) -> Path:
|
|
324
|
+
"""One CSV per table and postgres.sql: `psql "$DATABASE_URL" -f postgres.sql` in that folder creates and loads them."""
|
|
325
|
+
import csv
|
|
326
|
+
folder = Path(folder)
|
|
327
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
328
|
+
loads = []
|
|
329
|
+
for name, rows in tables.items():
|
|
330
|
+
cols = list(COLUMNS[name])
|
|
331
|
+
with open(folder / f"{name}.csv", "w", encoding="utf-8", newline="") as fh:
|
|
332
|
+
w = csv.writer(fh)
|
|
333
|
+
w.writerow(cols)
|
|
334
|
+
for r in rows:
|
|
335
|
+
w.writerow(["" if r.get(c) is None else r.get(c) for c in cols])
|
|
336
|
+
if rows:
|
|
337
|
+
loads.append(f"\\copy {name} FROM '{name}.csv' WITH (FORMAT csv, HEADER true)")
|
|
338
|
+
text = ("-- DevLake's tables from a StackVitals export. Run in this folder: psql \"$DATABASE_URL\" -f postgres.sql\n"
|
|
339
|
+
"-- Loading twice adds the rows again: start from an empty database, or delete the rows first.\n\n"
|
|
340
|
+
+ postgres_ddl() + "\n\n" + "\n".join(loads) + "\n")
|
|
341
|
+
(folder / "postgres.sql").write_text(text, encoding="utf-8", newline="\n")
|
|
342
|
+
return folder
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""`stackvitals export owner/repo --format devlake`: a repo's pull requests, PR metrics, deployments and incidents in
|
|
2
|
+
Apache DevLake's table shapes, with DevLake's DORA dashboard SQL ported to DuckDB beside them (dora.sql)."""
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from mergescout.github.graphql import GraphQLError
|
|
11
|
+
from stackvitals.ci import cli as ci_cli
|
|
12
|
+
from stackvitals.ci.api import APIError, OutOfBudget
|
|
13
|
+
from stackvitals.core import export, services
|
|
14
|
+
|
|
15
|
+
FORMATS = ("devlake", "ctrf", "postgres", "otel")
|
|
16
|
+
LABELS = {"deployment_frequency": "Deployment frequency", "lead_time": "Median lead time for changes (hours)",
|
|
17
|
+
"change_failure_rate": "Change failure rate", "recovery": "Recovery time (hours)"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _now() -> datetime:
|
|
21
|
+
return datetime.now(timezone.utc)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(argv: list[str] | None = None, tok: str | None = None) -> int:
|
|
25
|
+
parser = argparse.ArgumentParser(prog="stackvitals export", description="Export a repo's delivery data in another tool's shape.")
|
|
26
|
+
parser.add_argument("repo", help="owner/name")
|
|
27
|
+
parser.add_argument("--format", default="devlake",
|
|
28
|
+
help="devlake (Apache DevLake's domain tables), ctrf (tests as a CTRF report, with their history) or "
|
|
29
|
+
"postgres (DevLake's tables as CSV plus postgres.sql to create and load them) or otel "
|
|
30
|
+
"(CI runs, jobs, steps and failed tests as OpenTelemetry traces, OTLP JSON)")
|
|
31
|
+
parser.add_argument("--out", help="where to write (default: ./<owner>__<name>-devlake, or <owner>__<name>-ctrf.json)")
|
|
32
|
+
parser.add_argument("--days", type=int, default=90, help="history to read (max 90 for CI runs)")
|
|
33
|
+
parser.add_argument("--tests", action="store_true",
|
|
34
|
+
help="otel: also download test reports, for failed and flaky tests as events (one request per artifact)")
|
|
35
|
+
parser.add_argument("--report", default=None, help="DORA report for the panels: 2023 or 2021 (default: the repo's setting)")
|
|
36
|
+
args = parser.parse_args(argv)
|
|
37
|
+
if args.format not in FORMATS:
|
|
38
|
+
print(f"Unknown format {args.format!r}; formats: {', '.join(FORMATS)}.", file=sys.stderr)
|
|
39
|
+
return 2
|
|
40
|
+
tok = tok or ci_cli.token()
|
|
41
|
+
if not tok:
|
|
42
|
+
print("A GitHub token is needed. Set GITHUB_TOKEN, or run `gh auth login`.", file=sys.stderr)
|
|
43
|
+
return 1
|
|
44
|
+
days = max(1, min(args.days, 90))
|
|
45
|
+
now = _now()
|
|
46
|
+
if args.format == "ctrf":
|
|
47
|
+
return _ctrf(args, tok, days, now)
|
|
48
|
+
if args.format == "otel":
|
|
49
|
+
return _otel(args, tok, days, now)
|
|
50
|
+
try:
|
|
51
|
+
flow, info, ci, issues, cfg = services.delivery_inputs(args.repo, days, tok, ci_cli.home(), now=now,
|
|
52
|
+
log=lambda line: print(line, file=sys.stderr, flush=True))
|
|
53
|
+
except (APIError, OutOfBudget, GraphQLError, httpx.HTTPError) as exc:
|
|
54
|
+
print(f"Could not read {args.repo}: {exc}", file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
report = args.report or cfg["delivery"]["dora_report"]
|
|
57
|
+
out = Path(args.out or f"{args.repo.replace('/', '__')}-{args.format}")
|
|
58
|
+
start = now - timedelta(days=days)
|
|
59
|
+
tables = export.devlake_tables(args.repo, flow, info, ci, issues, cfg)
|
|
60
|
+
if args.format == "postgres":
|
|
61
|
+
export.write_postgres(tables, out)
|
|
62
|
+
print(f"Wrote {sum(len(v) for v in tables.values())} rows in {len(tables)} tables to {out}. Load them with: "
|
|
63
|
+
f"cd {out} && psql \"$DATABASE_URL\" -f postgres.sql")
|
|
64
|
+
return 0
|
|
65
|
+
export.write(tables, out, start, now, report, args.repo)
|
|
66
|
+
print(f"Wrote {sum(len(v) for v in tables.values())} rows in {len(tables)} DevLake tables to {out} "
|
|
67
|
+
f"({len(tables['cicd_deployment_commits'])} deployments, {len(tables['project_pr_metrics'])} merged PRs).")
|
|
68
|
+
try:
|
|
69
|
+
panels = export.panels(out, start, now, report, args.repo)
|
|
70
|
+
except ImportError:
|
|
71
|
+
print("Install DuckDB (pip install 'stackvitals[sql]') to see DevLake's DORA panels; or run `duckdb < dora.sql` there.")
|
|
72
|
+
return 0
|
|
73
|
+
print(f"DevLake's DORA dashboard ({report} report), computed by its own SQL on this export, last {days} days:")
|
|
74
|
+
for key, label in LABELS.items():
|
|
75
|
+
print(f" {label}: {panels[key] or 'no data'}")
|
|
76
|
+
print(" Monthly deployments: " + ", ".join(f"{m} {n}" for m, n in panels["monthly"]))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _otel(args, tok: str, days: int, now: datetime) -> int:
|
|
81
|
+
import json
|
|
82
|
+
|
|
83
|
+
from stackvitals.ci.api import ActionsAPI
|
|
84
|
+
from stackvitals.ci.collect import collect
|
|
85
|
+
from stackvitals.ci.store import DataStore
|
|
86
|
+
store = DataStore(ci_cli.home() / "ci" / args.repo.replace("/", "__"))
|
|
87
|
+
try:
|
|
88
|
+
collect(ActionsAPI(tok, args.repo, max_requests=3000), store, now, days=days, tests=args.tests,
|
|
89
|
+
log=lambda line: print(line, file=sys.stderr, flush=True))
|
|
90
|
+
except (APIError, OutOfBudget, httpx.HTTPError) as exc:
|
|
91
|
+
print(f"Stopped reading {args.repo} early ({exc}); exporting what was read.", file=sys.stderr)
|
|
92
|
+
traces = export.otel_traces(args.repo, store.load())
|
|
93
|
+
out = Path(args.out or f"{args.repo.replace('/', '__')}-otel.json")
|
|
94
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
out.write_text(json.dumps(traces), encoding="utf-8")
|
|
96
|
+
spans = traces["resourceSpans"][0]["scopeSpans"][0]["spans"]
|
|
97
|
+
print(f"Wrote {sum('parentSpanId' not in s for s in spans)} runs as {len(spans)} spans to {out}. Send it to any "
|
|
98
|
+
f"OpenTelemetry collector: curl -X POST -H 'Content-Type: application/json' --data @{out} <collector>/v1/traces")
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ctrf(args, tok: str, days: int, now: datetime) -> int:
|
|
103
|
+
import json
|
|
104
|
+
try:
|
|
105
|
+
result, source = services.ci_result(args.repo, days, tok, ci_cli.home(),
|
|
106
|
+
log=lambda line: print(line, file=sys.stderr, flush=True))
|
|
107
|
+
except (APIError, OutOfBudget, httpx.HTTPError) as exc:
|
|
108
|
+
print(f"Could not read {args.repo}: {exc}", file=sys.stderr)
|
|
109
|
+
return 1
|
|
110
|
+
report = export.ctrf(args.repo, result, now - timedelta(days=result.get("window_days") or days), now)
|
|
111
|
+
out = Path(args.out or f"{args.repo.replace('/', '__')}-ctrf.json")
|
|
112
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
out.write_text(json.dumps(report, indent=1), encoding="utf-8")
|
|
114
|
+
s = report["results"]["summary"]
|
|
115
|
+
print(f"Wrote {s['tests']} tests ({s['failed']} broken now, {sum(t['flaky'] for t in report['results']['tests'])} flaky) "
|
|
116
|
+
f"as CTRF to {out} ({source}).")
|
|
117
|
+
return 0
|