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,239 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
from stackvitals.ci.api import ActionsAPI, APIError, OutOfBudget
|
|
6
|
+
from stackvitals.ci.store import DataStore
|
|
7
|
+
from stackvitals.ci import coverage
|
|
8
|
+
from stackvitals.ci.testreports import _empty, parse_archive
|
|
9
|
+
|
|
10
|
+
PR_EVENTS = {"pull_request", "pull_request_target"}
|
|
11
|
+
# New test ids kept per report, and the most test ids remembered (past that, nothing is called new).
|
|
12
|
+
NEW_KEEP, KNOWN_MAX = 50, 50_000
|
|
13
|
+
# Artifact names that usually hold test reports; users can pass their own pattern.
|
|
14
|
+
TEST_REPORTS = (r"junit|test[-_ ]?results?|test[-_ ]?reports?|ctrf|surefire|pytest|jest|vitest|playwright|trx|go[-_ ]?test"
|
|
15
|
+
r"|xunit|nunit|mstest")
|
|
16
|
+
# Coverage reports too, but not HTML coverage sites (nothing to read there).
|
|
17
|
+
TEST_ARTIFACTS = TEST_REPORTS + r"|lcov|jacoco|cobertura|coverage(?![-_ ]?html)"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_record(body: dict, n: int) -> dict:
|
|
21
|
+
# pull_requests on non-PR runs can list unrelated fork PRs, so it is trusted only for PR events.
|
|
22
|
+
prs = [p["number"] for p in body.get("pull_requests") or []] if body.get("event") in PR_EVENTS else []
|
|
23
|
+
rec = {"t": "run", "id": body["id"], "att": n, "wf": body.get("workflow_id"), "wfn": body.get("name"),
|
|
24
|
+
"path": body.get("path"), "ev": body.get("event"), "br": body.get("head_branch"),
|
|
25
|
+
"sha": body.get("head_sha"), "tree": (body.get("head_commit") or {}).get("tree_id"), "pr": prs,
|
|
26
|
+
"c": body.get("conclusion"), "cr": body.get("created_at"), "st": body.get("run_started_at"),
|
|
27
|
+
"up": body.get("updated_at")}
|
|
28
|
+
if body.get("event") in PR_EVENTS:
|
|
29
|
+
# Fork PR runs list no PR numbers; the head owner and branch find the PR later.
|
|
30
|
+
rec["ho"] = ((body.get("head_repository") or {}).get("owner") or {}).get("login")
|
|
31
|
+
return rec
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def job_record(j: dict, run_id: int, n: int) -> dict:
|
|
35
|
+
steps = [[s.get("number"), s.get("name"), s.get("conclusion"), s.get("started_at"), s.get("completed_at")]
|
|
36
|
+
for s in j.get("steps") or []]
|
|
37
|
+
return {"t": "job", "run": run_id, "att": n, "id": j["id"], "n": j.get("name"), "c": j.get("conclusion"),
|
|
38
|
+
"cr": j.get("created_at"), "st": j.get("started_at"), "en": j.get("completed_at"),
|
|
39
|
+
"lb": j.get("labels") or [], "steps": steps}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fetch(api: ActionsAPI, run: dict, n: int) -> list[dict]:
|
|
43
|
+
body = run if n == run.get("run_attempt", 1) else api.attempt(run["id"], n)
|
|
44
|
+
return [run_record(body, n)] + [job_record(j, run["id"], n) for j in api.jobs(run["id"], n)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _attempt_of(created: str | None, starts: dict[int, str]) -> int:
|
|
48
|
+
# An artifact belongs to the last attempt that had started before it was uploaded.
|
|
49
|
+
started = [att for att, st in starts.items() if st and created and st <= created]
|
|
50
|
+
return max(started) if started else min(starts or {1: None})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _pr_number(api: ActionsAPI, run: dict) -> int | None:
|
|
54
|
+
"""The pull request a pull_request run tested: the one GitHub names, else (forks) the one with that head branch."""
|
|
55
|
+
if run.get("event") != "pull_request":
|
|
56
|
+
return None
|
|
57
|
+
named = [p.get("number") for p in run.get("pull_requests") or [] if p.get("number")]
|
|
58
|
+
if named:
|
|
59
|
+
return named[0]
|
|
60
|
+
owner = ((run.get("head_repository") or {}).get("owner") or {}).get("login")
|
|
61
|
+
if not owner or not run.get("head_branch"):
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
prs = api._get(f"/repos/{api.repo}/pulls", {"head": f"{owner}:{run['head_branch']}", "state": "all", "per_page": 5})
|
|
65
|
+
except APIError:
|
|
66
|
+
return None
|
|
67
|
+
same = [p for p in prs or [] if (p.get("head") or {}).get("sha") == run.get("head_sha")]
|
|
68
|
+
return (same or prs or [{}])[0].get("number")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _pr_files(api: ActionsAPI, number: int, pages: int = 10) -> list[dict]:
|
|
72
|
+
out = []
|
|
73
|
+
for page in range(1, pages + 1):
|
|
74
|
+
got = api._get(f"/repos/{api.repo}/pulls/{number}/files", {"per_page": 100, "page": page}) or []
|
|
75
|
+
out += got
|
|
76
|
+
if len(got) < 100:
|
|
77
|
+
break
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _reports(api: ActionsAPI, run: dict, starts: dict[int, str], pattern: str, max_bytes: int,
|
|
82
|
+
done_arts: set) -> list[dict]:
|
|
83
|
+
out, diff = [], {}
|
|
84
|
+
for art in api.artifacts(run["id"]):
|
|
85
|
+
if (art["id"] in done_arts or art.get("expired") or not re.search(pattern, art.get("name") or "", re.I)
|
|
86
|
+
or (art.get("size_in_bytes") or 0) > max_bytes):
|
|
87
|
+
continue
|
|
88
|
+
data = api.download(art["id"]) # a GitHub error here is retried on the next run
|
|
89
|
+
try:
|
|
90
|
+
parsed = parse_archive(data)
|
|
91
|
+
except Exception: # a broken or hostile artifact is noted once and never read again
|
|
92
|
+
out.append({"t": "tests", "run": run["id"], "att": _attempt_of(art.get("created_at"), starts), "art": art["id"],
|
|
93
|
+
"name": art.get("name"), **{k: v for k, v in _empty().items() if k != "all"}, "unreadable": True})
|
|
94
|
+
continue
|
|
95
|
+
cov = parsed.pop("cov", None)
|
|
96
|
+
rec = {"t": "tests", "run": run["id"], "att": _attempt_of(art.get("created_at"), starts), "art": art["id"],
|
|
97
|
+
"name": art.get("name"), **parsed}
|
|
98
|
+
if cov:
|
|
99
|
+
# Only the totals are kept; the line hits are read once, for the pull request's patch coverage.
|
|
100
|
+
rec["cov"] = {"format": cov["format"], "lines": cov["lines"], "covered": cov["covered"]}
|
|
101
|
+
# The run's pull request and its files are read once, however many coverage artifacts the matrix uploads.
|
|
102
|
+
if "pr" not in diff:
|
|
103
|
+
diff["pr"] = _pr_number(api, run)
|
|
104
|
+
number = diff["pr"]
|
|
105
|
+
if number:
|
|
106
|
+
try:
|
|
107
|
+
if number not in diff:
|
|
108
|
+
diff[number] = _pr_files(api, number)
|
|
109
|
+
rec["patch"] = {"pr": number, **coverage.patch(cov, diff[number])}
|
|
110
|
+
except APIError:
|
|
111
|
+
pass
|
|
112
|
+
out.append(rec)
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _distrust(rec: dict, own: bool) -> None:
|
|
117
|
+
# A fork PR writes its own reports: a forged "passed on retry" could make a real test known flaky (and quarantined).
|
|
118
|
+
if not own:
|
|
119
|
+
rec["flaky"] = []
|
|
120
|
+
rec.pop("cov", None)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _learn(rec: dict, ids: list[str], seen: set, known: bool, own: bool) -> None:
|
|
124
|
+
"""Note a report's tests seen for the first time, and remember them up to the cap; a fork's runs teach nothing, so
|
|
125
|
+
its made-up ids can neither fill the list nor show up as new tests."""
|
|
126
|
+
if not own:
|
|
127
|
+
return
|
|
128
|
+
full = len(seen) >= KNOWN_MAX
|
|
129
|
+
new = [t for t in ids if t not in seen] if known and not full else []
|
|
130
|
+
if new:
|
|
131
|
+
rec["new"] = new[:NEW_KEEP]
|
|
132
|
+
room = KNOWN_MAX - len(seen)
|
|
133
|
+
if room > 0:
|
|
134
|
+
seen.update([t for t in ids if t not in seen][:room])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def collect(api: ActionsAPI, store: DataStore, now: datetime, days: int = 90, exclude=(),
|
|
138
|
+
recheck_days: int = 3, workers: int = 10, log=None, tests: bool = False,
|
|
139
|
+
artifact_pattern: str = TEST_ARTIFACTS, max_artifact_bytes: int = 50_000_000) -> dict:
|
|
140
|
+
"""Store every new attempt of every completed run in the last `days` days, newest day first,
|
|
141
|
+
and (with tests=True) the test reports in each run's artifacts."""
|
|
142
|
+
state = store.state()
|
|
143
|
+
scanned = state.setdefault("scanned", {})
|
|
144
|
+
scanned_tests = state.setdefault("scanned_tests", {})
|
|
145
|
+
checked = state.setdefault("arts_checked", {})
|
|
146
|
+
loaded = store.load()
|
|
147
|
+
seen = {(r["id"], r["att"]) for r in loaded["runs"]}
|
|
148
|
+
done_arts = {r["art"] for r in loaded["tests"]}
|
|
149
|
+
# Test ids seen before; the first collect that reads reports sets the baseline and names nothing as new.
|
|
150
|
+
known = store.known_tests() if tests else None
|
|
151
|
+
seen_ids = set(known or ())
|
|
152
|
+
starts: dict[int, dict[int, str]] = {}
|
|
153
|
+
for r in loaded["runs"]:
|
|
154
|
+
starts.setdefault(r["id"], {})[r["att"]] = r.get("st")
|
|
155
|
+
today = now.date()
|
|
156
|
+
stamp = today.isoformat()
|
|
157
|
+
excluded = set(exclude)
|
|
158
|
+
touched, complete = set(), True
|
|
159
|
+
pool = ThreadPoolExecutor(max_workers=max(1, workers))
|
|
160
|
+
try:
|
|
161
|
+
for back in range(days + 1):
|
|
162
|
+
day = (today - timedelta(days=back)).isoformat()
|
|
163
|
+
# Re-runs land on old runs, so the last few days are always checked again.
|
|
164
|
+
jobs_done = scanned.get(day) == "done" and back > recheck_days
|
|
165
|
+
tests_done = not tests or (scanned_tests.get(day) == "done" and back > recheck_days)
|
|
166
|
+
if jobs_done and tests_done:
|
|
167
|
+
continue
|
|
168
|
+
# GitHub-managed workflows (Dependabot, CodeQL default setup, Pages) live under "dynamic/".
|
|
169
|
+
listed = [run for run in api.runs_on_day(day)
|
|
170
|
+
if run.get("path") not in excluded and not (run.get("path") or "").startswith("dynamic/")
|
|
171
|
+
and run.get("status") == "completed"]
|
|
172
|
+
todo = [(run, n) for run in listed for n in range(1, (run.get("run_attempt") or 1) + 1)
|
|
173
|
+
if (run["id"], n) not in seen]
|
|
174
|
+
futures = [pool.submit(_fetch, api, run, n) for run, n in todo]
|
|
175
|
+
stop, skipped = None, 0
|
|
176
|
+
for (run, n), fut in zip(todo, futures):
|
|
177
|
+
try:
|
|
178
|
+
records = fut.result()
|
|
179
|
+
except OutOfBudget as exc:
|
|
180
|
+
stop = stop or exc
|
|
181
|
+
continue
|
|
182
|
+
except APIError:
|
|
183
|
+
# One run GitHub can't serve right now is skipped; the day stays open for the next run.
|
|
184
|
+
skipped += 1
|
|
185
|
+
continue
|
|
186
|
+
# Each attempt is written as soon as it is read, under the day the run happened.
|
|
187
|
+
store.add(records, day)
|
|
188
|
+
seen.add((run["id"], n))
|
|
189
|
+
touched.add(run["id"])
|
|
190
|
+
starts.setdefault(run["id"], {})[n] = records[0].get("st")
|
|
191
|
+
if tests and not stop:
|
|
192
|
+
# Reports are read once per run and attempt count, so a later re-run's reports are read too.
|
|
193
|
+
mark = lambda run: f"{run['id']}:{run.get('run_attempt') or 1}" # noqa: E731
|
|
194
|
+
pending = [run for run in listed if mark(run) not in checked
|
|
195
|
+
and all((run["id"], n) in seen for n in range(1, (run.get("run_attempt") or 1) + 1))]
|
|
196
|
+
rfutures = [pool.submit(_reports, api, run, starts.get(run["id"], {}), artifact_pattern,
|
|
197
|
+
max_artifact_bytes, done_arts) for run in pending]
|
|
198
|
+
for run, fut in zip(pending, rfutures):
|
|
199
|
+
try:
|
|
200
|
+
reports = fut.result()
|
|
201
|
+
except OutOfBudget as exc:
|
|
202
|
+
stop = stop or exc
|
|
203
|
+
continue
|
|
204
|
+
except APIError:
|
|
205
|
+
skipped += 1
|
|
206
|
+
continue
|
|
207
|
+
own = (run.get("head_repository") or {}).get("full_name") in (None, api.repo)
|
|
208
|
+
for rec in reports:
|
|
209
|
+
_learn(rec, [t for t, _ms in rec.pop("all", [])], seen_ids, known is not None, own)
|
|
210
|
+
_distrust(rec, own)
|
|
211
|
+
store.add(reports, day)
|
|
212
|
+
done_arts.update(r["art"] for r in reports)
|
|
213
|
+
checked[mark(run)] = day
|
|
214
|
+
if log:
|
|
215
|
+
log(f"{day}: {len({r['id'] for r in listed})} run(s) read, {api.requests} requests so far")
|
|
216
|
+
if stop:
|
|
217
|
+
raise stop
|
|
218
|
+
if skipped:
|
|
219
|
+
complete = False
|
|
220
|
+
scanned[day] = "partial"
|
|
221
|
+
state["last_stop"] = f"{skipped} run(s) GitHub could not serve on {day}; retried next time"
|
|
222
|
+
else:
|
|
223
|
+
scanned[day] = "done"
|
|
224
|
+
if tests:
|
|
225
|
+
scanned_tests[day] = "done"
|
|
226
|
+
except OutOfBudget as exc:
|
|
227
|
+
complete = False
|
|
228
|
+
state["last_stop"] = str(exc)
|
|
229
|
+
finally:
|
|
230
|
+
pool.shutdown(wait=True)
|
|
231
|
+
oldest = (today - timedelta(days=days + recheck_days)).isoformat()
|
|
232
|
+
state["arts_checked"] = {k: v for k, v in checked.items() if v >= oldest}
|
|
233
|
+
state["last_collect"] = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
234
|
+
store.save_state(state)
|
|
235
|
+
if tests and seen_ids:
|
|
236
|
+
store.save_known_tests(seen_ids)
|
|
237
|
+
# Past days are compressed right away, so a big first backfill never sits in git uncompressed.
|
|
238
|
+
store.compress(stamp)
|
|
239
|
+
return {"runs": len(touched), "requests": api.requests, "complete": complete}
|
stackvitals/ci/cost.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""CI cost: billed minutes and money per workflow and job at GitHub's published per-minute prices (docs.github.com,
|
|
2
|
+
"Actions runner pricing", read 2026-10-29), each job rounded up to a whole minute as GitHub bills it; the minutes spent
|
|
3
|
+
on attempts that failed and then passed on a re-run (what flaky jobs cost); and, for a public repo, where standard
|
|
4
|
+
runners are free, what the same use would cost on a private one. Self-hosted runners count in minutes only."""
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
|
|
10
|
+
# USD per minute: standard runners, then larger ones by OS, architecture and cores.
|
|
11
|
+
STANDARD = {"linux": 0.006, "linux arm": 0.005, "windows": 0.010, "windows arm": 0.010, "macos": 0.062}
|
|
12
|
+
LARGER = {("linux", 4): 0.012, ("linux", 8): 0.022, ("linux", 16): 0.042, ("linux arm", 4): 0.008,
|
|
13
|
+
("linux arm", 8): 0.014, ("linux arm", 16): 0.026, ("windows", 8): 0.042, ("windows", 16): 0.082,
|
|
14
|
+
("windows arm", 8): 0.026, ("windows arm", 16): 0.050}
|
|
15
|
+
CORES = re.compile(r"(\d+)[-_ ]?(core|cores|cpu|vcpu)", re.I)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def rate(labels: list[str]) -> tuple[str, float | None]:
|
|
19
|
+
"""(runner kind, USD per minute) from a job's runner labels; self-hosted runners have no price."""
|
|
20
|
+
low = [x.lower() for x in labels or []]
|
|
21
|
+
if "self-hosted" in low:
|
|
22
|
+
return "self-hosted", None
|
|
23
|
+
text = " ".join(low)
|
|
24
|
+
os_ = "windows" if "windows" in text else "macos" if "macos" in text or "mac" in text else "linux"
|
|
25
|
+
if os_ != "macos" and ("arm" in text or "aarch64" in text):
|
|
26
|
+
os_ += " arm"
|
|
27
|
+
cores = CORES.search(text)
|
|
28
|
+
if cores and os_ != "macos":
|
|
29
|
+
n = int(cores.group(1))
|
|
30
|
+
size = min((s for (o, s) in LARGER if o == os_ and s >= n), default=None)
|
|
31
|
+
if size:
|
|
32
|
+
return f"{os_} {size}-core", LARGER[(os_, size)]
|
|
33
|
+
if os_ == "macos" and ("xlarge" in text or "large" in text):
|
|
34
|
+
return "macos large", 0.077 if "xlarge" not in text else 0.102
|
|
35
|
+
return os_, STANDARD[os_]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _minutes(j: dict) -> int:
|
|
39
|
+
try:
|
|
40
|
+
secs = (datetime.fromisoformat(j["en"].replace("Z", "+00:00")) - datetime.fromisoformat(j["st"].replace("Z", "+00:00"))).total_seconds()
|
|
41
|
+
except (KeyError, TypeError, AttributeError, ValueError):
|
|
42
|
+
return 0
|
|
43
|
+
return max(1, math.ceil(secs / 60)) if secs > 0 else 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def report(data: dict, now: datetime, private: bool, days: int = 30) -> dict:
|
|
47
|
+
since = (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
48
|
+
runs = {(r["id"], r["att"]): r for r in data.get("runs") or []}
|
|
49
|
+
jobs = [j for j in data.get("jobs") or [] if (j.get("st") or "") >= since and j.get("en")]
|
|
50
|
+
# A failed attempt followed by a later attempt of the same job that passed: the re-run's cost of a flake.
|
|
51
|
+
passed_later = {(j["run"], j["n"]) for j in jobs if j.get("c") == "success" and j.get("att", 1) > 1}
|
|
52
|
+
by_wf, by_job = defaultdict(lambda: [0, 0.0]), defaultdict(lambda: [0, 0.0, ""])
|
|
53
|
+
by_runner = defaultdict(int)
|
|
54
|
+
minutes = usd = private_usd = rerun_min = rerun_usd = self_hosted = 0
|
|
55
|
+
for j in jobs:
|
|
56
|
+
m = _minutes(j)
|
|
57
|
+
kind, price = rate(j.get("lb"))
|
|
58
|
+
run = runs.get((j["run"], j.get("att", 1))) or runs.get((j["run"], 1)) or {}
|
|
59
|
+
wf = run.get("wfn") or run.get("path") or "unknown workflow"
|
|
60
|
+
# Standard runners are free on public repos; larger runners never are.
|
|
61
|
+
paid = 0.0 if price is None else (m * price if private or kind not in STANDARD else 0.0)
|
|
62
|
+
minutes += m
|
|
63
|
+
usd += paid
|
|
64
|
+
private_usd += 0.0 if price is None else m * price
|
|
65
|
+
by_runner[kind] += m
|
|
66
|
+
if price is None:
|
|
67
|
+
self_hosted += m
|
|
68
|
+
by_wf[wf][0] += m
|
|
69
|
+
by_wf[wf][1] += paid
|
|
70
|
+
key = (wf, j.get("n") or "job")
|
|
71
|
+
by_job[key][0] += m
|
|
72
|
+
by_job[key][1] += paid
|
|
73
|
+
if j.get("c") in ("failure", "timed_out") and (j["run"], j.get("n")) in passed_later:
|
|
74
|
+
rerun_min += m
|
|
75
|
+
rerun_usd += paid
|
|
76
|
+
top = lambda d, label: [{label: k if isinstance(k, str) else k[1], **({"workflow": k[0]} if not isinstance(k, str) else {}), # noqa: E731
|
|
77
|
+
"minutes": v[0], "usd": round(v[1], 2)} for k, v in sorted(d.items(), key=lambda kv: -kv[1][0])][:20]
|
|
78
|
+
return {"days": days, "private": private, "minutes": minutes, "usd": round(usd, 2), "private_usd": round(private_usd, 2),
|
|
79
|
+
"rerun_minutes": rerun_min, "rerun_usd": round(rerun_usd, 2), "self_hosted_minutes": self_hosted,
|
|
80
|
+
"by_runner": dict(by_runner), "workflows": top(by_wf, "workflow"), "jobs": top(by_job, "job"),
|
|
81
|
+
"prices_read": "2026-10-29"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def flaky_tests(data: dict, tests: list[dict], private: bool, most: int = 20) -> list[dict]:
|
|
85
|
+
"""What each flaky test cost: the job minutes (and money) of the attempts it flaked in, an attempt's cost split
|
|
86
|
+
evenly between the tests that flaked in it, plus the pull requests it got in the way of; costliest first."""
|
|
87
|
+
by_attempt = defaultdict(list)
|
|
88
|
+
for j in data.get("jobs") or []:
|
|
89
|
+
if j.get("c") in ("failure", "timed_out") and j.get("en"):
|
|
90
|
+
by_attempt[(j["run"], j.get("att", 1))].append(j)
|
|
91
|
+
sharing = defaultdict(int)
|
|
92
|
+
for t in tests:
|
|
93
|
+
for a in t.get("_attempts") or []:
|
|
94
|
+
sharing[tuple(a)] += 1
|
|
95
|
+
out = []
|
|
96
|
+
for t in tests:
|
|
97
|
+
minutes = paid = 0.0
|
|
98
|
+
for a in t.get("_attempts") or []:
|
|
99
|
+
for j in by_attempt.get(tuple(a), []):
|
|
100
|
+
m = _minutes(j) / sharing[tuple(a)]
|
|
101
|
+
kind, price = rate(j.get("lb"))
|
|
102
|
+
minutes += m
|
|
103
|
+
paid += 0.0 if price is None else (m * price if private or kind not in STANDARD else 0.0)
|
|
104
|
+
if minutes:
|
|
105
|
+
out.append({"id": t.get("id"), "suite": t.get("suite"), "minutes": round(minutes, 1), "usd": round(paid, 2),
|
|
106
|
+
"flakes": len(t.get("_attempts") or []), "prs": len(t.get("prs_affected") or [])})
|
|
107
|
+
return sorted(out, key=lambda x: -x["minutes"])[:most]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Coverage from the reports CI already uploads: Cobertura XML (coverage.py, Istanbul, Go's converters), LCOV and JaCoCo
|
|
2
|
+
XML, read safely in memory; and patch coverage, the share of a pull request's added lines that ran, from its diff."""
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from defusedxml import DefusedXmlException
|
|
7
|
+
from defusedxml.ElementTree import ParseError, fromstring
|
|
8
|
+
|
|
9
|
+
HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
10
|
+
# JaCoCo always names its DTD; a DOCTYPE with no internal subset declares nothing, so it is dropped and the rest is read
|
|
11
|
+
# with DTDs forbidden (entity tricks stay impossible).
|
|
12
|
+
PLAIN_DOCTYPE = re.compile(rb"<!DOCTYPE[^\[>]*>")
|
|
13
|
+
MISSED_KEEP = 50
|
|
14
|
+
PROLOG = 4096 # a DOCTYPE only appears before the root element; scanning further was quadratic on crafted files
|
|
15
|
+
ABSOLUTE = re.compile(r"^(?:/|[A-Za-z]:/)")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _xml(data: bytes):
|
|
19
|
+
try:
|
|
20
|
+
return fromstring(PLAIN_DOCTYPE.sub(b"", data[:PROLOG], count=1) + data[PROLOG:], forbid_dtd=True)
|
|
21
|
+
except (DefusedXmlException, ParseError, ValueError):
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _total(files: dict) -> tuple[int, int]:
|
|
26
|
+
lines = sum(len(hits) for hits in files.values())
|
|
27
|
+
return lines, sum(1 for hits in files.values() for h in hits.values() if h > 0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _int(text, default=0) -> int:
|
|
31
|
+
try:
|
|
32
|
+
value = float(text)
|
|
33
|
+
except (TypeError, ValueError, OverflowError):
|
|
34
|
+
return default
|
|
35
|
+
return int(value) if math.isfinite(value) else default
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cobertura(root) -> dict:
|
|
39
|
+
files = {}
|
|
40
|
+
for cls in root.iter("class"):
|
|
41
|
+
name = cls.get("filename")
|
|
42
|
+
if not name:
|
|
43
|
+
continue
|
|
44
|
+
hits = files.setdefault(name, {})
|
|
45
|
+
for line in cls.iter("line"):
|
|
46
|
+
n = _int(line.get("number"), None)
|
|
47
|
+
if n is not None:
|
|
48
|
+
hits[n] = max(hits.get(n, 0), _int(line.get("hits")))
|
|
49
|
+
return files
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _jacoco(root) -> dict:
|
|
53
|
+
files = {}
|
|
54
|
+
for package in root.iter("package"):
|
|
55
|
+
for source in package.iter("sourcefile"):
|
|
56
|
+
hits = files.setdefault(f"{package.get('name')}/{source.get('name')}".strip("/"), {})
|
|
57
|
+
for line in source.iter("line"):
|
|
58
|
+
n = _int(line.get("nr"), None)
|
|
59
|
+
if n is not None:
|
|
60
|
+
hits[n] = _int(line.get("ci"))
|
|
61
|
+
return files
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _lcov(text: str) -> dict:
|
|
65
|
+
files, cur = {}, None
|
|
66
|
+
for line in text.split("\n"):
|
|
67
|
+
line = line.rstrip("\r")
|
|
68
|
+
if line.startswith("SF:"):
|
|
69
|
+
cur = files.setdefault(line[3:].strip(), {})
|
|
70
|
+
elif line.startswith("DA:") and cur is not None:
|
|
71
|
+
parts = line[3:].split(",")
|
|
72
|
+
if len(parts) >= 2:
|
|
73
|
+
n = _int(parts[0], None)
|
|
74
|
+
if n is not None:
|
|
75
|
+
cur[n] = max(cur.get(n, 0), _int(parts[1]))
|
|
76
|
+
elif line.startswith("end_of_record"):
|
|
77
|
+
cur = None
|
|
78
|
+
return files
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse(name: str, data: bytes) -> dict | None:
|
|
82
|
+
"""{"format", "lines", "covered", "files": {path: {line: hits}}} for a coverage report, else None."""
|
|
83
|
+
data = data.removeprefix(b"\xef\xbb\xbf") # a UTF-8 byte order mark
|
|
84
|
+
low, head = name.lower(), data.lstrip()[:200]
|
|
85
|
+
if low.endswith((".info", ".lcov")) or head.startswith((b"TN:", b"SF:")):
|
|
86
|
+
files, kind = _lcov(data.decode("utf-8", "replace")), "lcov"
|
|
87
|
+
elif head.startswith(b"<"):
|
|
88
|
+
if b"<coverage" not in data[:2000] and b"<report" not in data[:2000]:
|
|
89
|
+
return None
|
|
90
|
+
root = _xml(data)
|
|
91
|
+
if root is None or root.tag not in ("coverage", "report"):
|
|
92
|
+
return None
|
|
93
|
+
files, kind = (_cobertura(root), "cobertura") if root.tag == "coverage" else (_jacoco(root), "jacoco")
|
|
94
|
+
else:
|
|
95
|
+
return None
|
|
96
|
+
files = {path: hits for path, hits in files.items() if hits}
|
|
97
|
+
if not files:
|
|
98
|
+
return None
|
|
99
|
+
lines, covered = _total(files)
|
|
100
|
+
return {"format": kind, "lines": lines, "covered": covered, "files": files}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def added_lines(patch: str | None) -> list[int]:
|
|
104
|
+
"""The new file's line numbers of the lines a unified diff adds."""
|
|
105
|
+
out, cur = [], None
|
|
106
|
+
# Only a newline ends a diff line: splitlines() also splits on form feeds and the like inside a line.
|
|
107
|
+
for line in (patch or "").split("\n"):
|
|
108
|
+
line = line.removesuffix("\r")
|
|
109
|
+
found = HUNK.match(line)
|
|
110
|
+
if found:
|
|
111
|
+
cur = int(found.group(1))
|
|
112
|
+
elif cur is None or line.startswith("\\"):
|
|
113
|
+
continue
|
|
114
|
+
elif line.startswith("+"):
|
|
115
|
+
out.append(cur)
|
|
116
|
+
cur += 1
|
|
117
|
+
elif not line.startswith("-"):
|
|
118
|
+
cur += 1
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _match(name: str, paths: list[str]) -> str | None:
|
|
123
|
+
"""The report's path for a repo file: the same path; else an absolute runner path ending with it (the shortest);
|
|
124
|
+
else the longest path it ends with (paths relative to a source folder). A deeper relative path is another file."""
|
|
125
|
+
norm = {p.replace("\\", "/"): p for p in paths}
|
|
126
|
+
if name in norm:
|
|
127
|
+
return norm[name]
|
|
128
|
+
tail = "/" + name
|
|
129
|
+
absolute = [p for p in norm if p.endswith(tail) and ABSOLUTE.match(p)]
|
|
130
|
+
if absolute:
|
|
131
|
+
return norm[min(absolute, key=len)]
|
|
132
|
+
inner = [p for p in norm if name.endswith("/" + p)]
|
|
133
|
+
return norm[max(inner, key=len)] if inner else None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def patch(cov: dict, files: list[dict]) -> dict:
|
|
137
|
+
"""{"lines", "covered", "missed": {file: [lines]}} over the added lines the report knows (lines it doesn't list, like
|
|
138
|
+
docs or blank lines, aren't code that could run)."""
|
|
139
|
+
paths = list(cov.get("files") or {})
|
|
140
|
+
lines = covered = 0
|
|
141
|
+
missed = {}
|
|
142
|
+
for f in files:
|
|
143
|
+
name = f.get("filename") or ""
|
|
144
|
+
path = _match(name, paths)
|
|
145
|
+
if not path:
|
|
146
|
+
continue
|
|
147
|
+
hits = cov["files"][path]
|
|
148
|
+
for n in added_lines(f.get("patch")):
|
|
149
|
+
if n in hits:
|
|
150
|
+
lines += 1
|
|
151
|
+
if hits[n] > 0:
|
|
152
|
+
covered += 1
|
|
153
|
+
else:
|
|
154
|
+
missed.setdefault(name, []).append(n)
|
|
155
|
+
return {"lines": lines, "covered": covered, "missed": {k: v[:MISSED_KEEP] for k, v in missed.items()}}
|