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,264 @@
|
|
|
1
|
+
"""The org's repos and what is known about each: settings, custom properties, branch rules, and the suite's own module
|
|
2
|
+
results read from each repo's stackvitals-data branch (where the suite runs)."""
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from stackvitals.ci.api import APIError
|
|
10
|
+
from stackvitals.core.actors import INSIDER_ASSOC, People, kind_of
|
|
11
|
+
from stackvitals.core.store import accept
|
|
12
|
+
from stackvitals.portal import descriptor
|
|
13
|
+
from stackvitals.portal.github import NA, REFUSED, read, read_file
|
|
14
|
+
|
|
15
|
+
DATA_BRANCH = "stackvitals-data"
|
|
16
|
+
FILES = ("readme", "license", "contributing", "code_of_conduct")
|
|
17
|
+
SCORECARD_API = "https://api.scorecard.dev/projects/github.com/"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _repo(r: dict) -> dict:
|
|
21
|
+
return {"name": r["full_name"], "private": bool(r.get("private")), "archived": bool(r.get("archived")),
|
|
22
|
+
"fork": bool(r.get("fork")), "language": r.get("language"), "pushed": r.get("pushed_at"),
|
|
23
|
+
"default_branch": r.get("default_branch"), "topics": r.get("topics") or [], "stars": r.get("stargazers_count"),
|
|
24
|
+
"license": (r.get("license") or {}).get("key")}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def list_repos(api, org: str, mode: str, max_pages: int = 50) -> list[dict]:
|
|
28
|
+
"""Private mode: every repo the org's App is installed on. Public mode: the org's public repos (or a user's)."""
|
|
29
|
+
found = []
|
|
30
|
+
for page in range(1, max_pages + 1):
|
|
31
|
+
if mode == "private":
|
|
32
|
+
body = read(api, "/installation/repositories", {"per_page": 100, "page": page})
|
|
33
|
+
batch = [] if body is NA else body.get("repositories") or []
|
|
34
|
+
else:
|
|
35
|
+
# Local runs list what the user's own token can see; the Action's public mode lists public repos only.
|
|
36
|
+
body = read(api, f"/orgs/{org}/repos", {"type": "all" if mode == "local" else "public", "per_page": 100, "page": page})
|
|
37
|
+
if body is NA and page == 1:
|
|
38
|
+
body = read(api, f"/users/{org}/repos", {"type": "owner", "per_page": 100, "page": page})
|
|
39
|
+
batch = [] if body is NA else body
|
|
40
|
+
found += [_repo(r) for r in batch if isinstance(r, dict) and r.get("full_name")]
|
|
41
|
+
if len(batch) < 100:
|
|
42
|
+
break
|
|
43
|
+
return sorted({r["name"]: r for r in found}.values(), key=lambda r: r["name"].lower())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def custom_properties(api, org: str) -> dict:
|
|
47
|
+
# Tier, lifecycle, system: GitHub's own repo metadata, read rather than rebuilt (one paged call for the whole org).
|
|
48
|
+
out = {}
|
|
49
|
+
for page in range(1, 51):
|
|
50
|
+
body = read(api, f"/orgs/{org}/properties/values", {"per_page": 100, "page": page})
|
|
51
|
+
if body is NA or not isinstance(body, list):
|
|
52
|
+
return out
|
|
53
|
+
for r in body:
|
|
54
|
+
out[r.get("repository_full_name")] = {p.get("property_name"): p.get("value") for p in r.get("properties") or []}
|
|
55
|
+
if len(body) < 100:
|
|
56
|
+
return out
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def branch_rules(api, repo: str, branch: str | None) -> list[str] | None:
|
|
61
|
+
# The rules that apply to the default branch, from rulesets and branch protection alike.
|
|
62
|
+
body = read(api, f"/repos/{repo}/rules/branches/{branch}") if branch else NA
|
|
63
|
+
return None if body is NA else sorted({r.get("type") for r in body if isinstance(r, dict) and r.get("type")})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _json(api, repo: str, path: str) -> dict | None:
|
|
67
|
+
text = read_file(api, repo, path, DATA_BRANCH)
|
|
68
|
+
try:
|
|
69
|
+
data = json.loads(text) if text is not NA else None
|
|
70
|
+
except ValueError:
|
|
71
|
+
return None
|
|
72
|
+
return accept(data)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def module_facts(api, repo: str, pre: dict | None = None) -> dict | None:
|
|
76
|
+
"""The suite's results for this repo, or None where the suite doesn't run (no data branch)."""
|
|
77
|
+
if (not pre["data_branch"]) if pre else read(api, f"/repos/{repo}/branches/{DATA_BRANCH}") is NA:
|
|
78
|
+
return None
|
|
79
|
+
# Since Phase 11 one file holds every fact as a number; older installs keep the three files below.
|
|
80
|
+
app = None
|
|
81
|
+
if pre and pre.get("app"):
|
|
82
|
+
try:
|
|
83
|
+
app = accept(json.loads(pre["app"]))
|
|
84
|
+
except ValueError:
|
|
85
|
+
app = None
|
|
86
|
+
f = (app or _json(api, repo, "docs/app.json") or {}).get("facts")
|
|
87
|
+
if isinstance(f, dict):
|
|
88
|
+
share, score = f.get("ci_first_try"), f.get("community_score")
|
|
89
|
+
return {"ci_first_try": f"{share:.0%}" if isinstance(share, (int, float)) else None, "ci_first_try_share": share,
|
|
90
|
+
"flow": {"open": f.get("open_prs"), "stale": f.get("stale_prs"),
|
|
91
|
+
"first_review_median_s": f.get("first_review_median_s")} if f.get("open_prs") is not None else None,
|
|
92
|
+
"community_score": f"{score}/100" if isinstance(score, (int, float)) else None,
|
|
93
|
+
# Every number the repo publishes, for the org view.
|
|
94
|
+
"facts": {k: v for k, v in f.items() if isinstance(v, (int, float)) and not isinstance(v, bool)}}
|
|
95
|
+
ci, flow, community = _json(api, repo, "docs/badge.json"), _json(api, repo, "docs/flow.json"), \
|
|
96
|
+
_json(api, repo, "docs/community-score.json")
|
|
97
|
+
pickup = ((flow or {}).get("stages") or {}).get("pickup") or {}
|
|
98
|
+
return {"ci_first_try": (ci or {}).get("message"),
|
|
99
|
+
"flow": {"open": flow.get("open"), "stale": flow.get("stale"), "first_review_median_s": pickup.get("median_s")}
|
|
100
|
+
if flow else None,
|
|
101
|
+
"community_score": (community or {}).get("message")}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def repo_files(api, repo: dict, pre: dict | None = None) -> dict:
|
|
105
|
+
"""README, licence, contributing guide and code of conduct: True, False, or None when nobody could check."""
|
|
106
|
+
if pre:
|
|
107
|
+
return pre["files"]
|
|
108
|
+
body = read(api, f"/repos/{repo['name']}/community/profile")
|
|
109
|
+
if body is not NA and isinstance(body, dict) and isinstance(body.get("files"), dict):
|
|
110
|
+
files = body["files"]
|
|
111
|
+
owner = repo["name"].split("/")[0].lower()
|
|
112
|
+
# GitHub fills gaps from the org's .github repo; the evidence says so.
|
|
113
|
+
defaults = [k for k in FILES if isinstance(files.get(k), dict)
|
|
114
|
+
and f"/{owner}/.github/" in (files[k].get("html_url") or "").lower()]
|
|
115
|
+
return {**{k: bool(files.get(k)) or (k == "license" and bool(repo.get("license"))) for k in FILES}, "org_defaults": defaults}
|
|
116
|
+
# Private repos may refuse the profile: the README is asked for directly and the licence comes from the repo itself.
|
|
117
|
+
return {"readme": read(api, f"/repos/{repo['name']}/readme") is not NA, "license": bool(repo.get("license")),
|
|
118
|
+
"contributing": None, "code_of_conduct": None}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def descriptor_for(api, repo: dict, pre: dict | None = None) -> dict:
|
|
122
|
+
"""The repo's catalog-info.yaml as typed entities and their problems (one request), or present: False."""
|
|
123
|
+
if pre:
|
|
124
|
+
text = pre["descriptor"] if pre["descriptor"] is not None else NA
|
|
125
|
+
else:
|
|
126
|
+
text = read_file(api, repo["name"], descriptor.FILE, repo.get("default_branch"))
|
|
127
|
+
if text is NA:
|
|
128
|
+
return {"present": False}
|
|
129
|
+
entities, problems = descriptor.parse(text)
|
|
130
|
+
return {"present": True, "entities": entities, "problems": problems}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def file_facts(api, repo: dict, rules: list[dict], pre: dict | None = None) -> dict:
|
|
134
|
+
"""For portal.toml's file rules: whether each file is there and whether its patterns match (one request per file).
|
|
135
|
+
Only these yes/no answers are kept, never the file's text."""
|
|
136
|
+
out = {}
|
|
137
|
+
for path in dict.fromkeys(r["path"] for r in rules):
|
|
138
|
+
if pre and path in pre["rule_files"]:
|
|
139
|
+
text = pre["rule_files"][path] if pre["rule_files"][path] is not None else NA
|
|
140
|
+
else:
|
|
141
|
+
text = read_file(api, repo["name"], path, repo.get("default_branch"))
|
|
142
|
+
if text is NA:
|
|
143
|
+
out[path] = {"present": False}
|
|
144
|
+
continue
|
|
145
|
+
found = {"present": True}
|
|
146
|
+
for r in rules:
|
|
147
|
+
for key in ("contains", "lacks"):
|
|
148
|
+
if r["path"] == path and r.get(key):
|
|
149
|
+
found[f"{key}:{r[key]}"] = bool(re.search(r[key], text[:descriptor.MAX_BYTES], re.M))
|
|
150
|
+
out[path] = found
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def scorecard_checks(client: httpx.Client, name: str, previous: dict | None, now: datetime) -> dict | None:
|
|
155
|
+
"""OpenSSF Scorecard single checks for a public repo, from its public API (results change weekly, so read once a week)."""
|
|
156
|
+
stamp = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
157
|
+
if previous and (previous.get("fetched") or "") >= (now - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ"):
|
|
158
|
+
return previous
|
|
159
|
+
try:
|
|
160
|
+
# The API matches names case-sensitively and lists them in lower case.
|
|
161
|
+
resp = client.get(SCORECARD_API + name.lower(), timeout=20)
|
|
162
|
+
except httpx.HTTPError:
|
|
163
|
+
return previous
|
|
164
|
+
if resp.status_code == 404:
|
|
165
|
+
return {"fetched": stamp, "date": None, "checks": None}
|
|
166
|
+
if resp.status_code != 200:
|
|
167
|
+
return previous
|
|
168
|
+
try:
|
|
169
|
+
body = resp.json()
|
|
170
|
+
except ValueError:
|
|
171
|
+
return previous
|
|
172
|
+
return {"fetched": stamp, "date": body.get("date"),
|
|
173
|
+
"checks": {c["name"]: c.get("score") for c in body.get("checks") or [] if isinstance(c, dict) and c.get("name")}}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def push_ci(api, repo: str, now: datetime, days: int = 30, previous: dict | None = None) -> dict | None:
|
|
177
|
+
"""First-try green and time to green for the newest 50 push runs, measured exactly like the crawl's CI benchmark,
|
|
178
|
+
for repos where CI Health doesn't run (one request; free when no run happened since the last check, which then
|
|
179
|
+
keeps the last check's numbers)."""
|
|
180
|
+
from mergescout.bench.ci import PAGE, repo_stats
|
|
181
|
+
path, params = f"/repos/{repo}/actions/runs", {"event": "push", "per_page": PAGE, "exclude_pull_requests": "true"}
|
|
182
|
+
tag = None
|
|
183
|
+
if hasattr(api, "get_if_changed"):
|
|
184
|
+
try:
|
|
185
|
+
body, tag = api.get_if_changed(path, params, (previous or {}).get("etag"))
|
|
186
|
+
except APIError as exc:
|
|
187
|
+
if not any(code in str(exc) for code in REFUSED):
|
|
188
|
+
raise
|
|
189
|
+
body = NA
|
|
190
|
+
if body is None:
|
|
191
|
+
return previous
|
|
192
|
+
else:
|
|
193
|
+
body = read(api, path, params)
|
|
194
|
+
runs = (body or {}).get("workflow_runs") if body is not NA and isinstance(body, dict) else None
|
|
195
|
+
if not runs:
|
|
196
|
+
return None
|
|
197
|
+
stats = repo_stats(runs, now - timedelta(days=days), page_full=len(runs) >= PAGE)
|
|
198
|
+
return {**{k: stats[k] for k in ("first_try_share", "pushes", "median_s")}, **({"etag": tag} if tag else {})} if stats else None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
REVIEW_QUERY = """
|
|
202
|
+
query($owner:String!,$name:String!) {
|
|
203
|
+
rateLimit { cost remaining resetAt }
|
|
204
|
+
repository(owner:$owner,name:$name) {
|
|
205
|
+
pullRequests(last:50, orderBy:{field:CREATED_AT, direction:ASC}) {
|
|
206
|
+
nodes { createdAt author { __typename login } authorAssociation
|
|
207
|
+
reviews(first:10) { nodes { submittedAt author { login } authorAssociation } } }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}"""
|
|
211
|
+
MAINTAINERS = INSIDER_ASSOC
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _review_records(nodes: list[dict]) -> list[dict]:
|
|
215
|
+
# The newest PRs as the shared role rule reads them (author, label, reviews); no merge events in this query.
|
|
216
|
+
out = []
|
|
217
|
+
for p in nodes:
|
|
218
|
+
author = p.get("author") or {}
|
|
219
|
+
out.append({"au": author.get("login"), "at": author.get("__typename"), "as": p.get("authorAssociation"),
|
|
220
|
+
"ev": [["review", r.get("submittedAt"), (r.get("author") or {}).get("login"), None, r.get("authorAssociation"), None]
|
|
221
|
+
for r in (p.get("reviews") or {}).get("nodes") or [] if r], "cm": []})
|
|
222
|
+
return out
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def review_stats(gql, repo: str, now: datetime, days: int = 90, min_prs: int = 10) -> dict | None:
|
|
226
|
+
"""Median time to the first review by a maintainer other than the author, for people's PRs of the last 90 days among
|
|
227
|
+
the newest 50 (the crawl benchmark's definition), where Team Flow doesn't run (one GraphQL point)."""
|
|
228
|
+
from mergescout.github.graphql import GraphQLError, RateLimited
|
|
229
|
+
owner, name = repo.split("/", 1)
|
|
230
|
+
try:
|
|
231
|
+
data = gql.query(REVIEW_QUERY, {"owner": owner, "name": name}).data
|
|
232
|
+
except (GraphQLError, RateLimited):
|
|
233
|
+
return None
|
|
234
|
+
nodes = ((data.get("repository") or {}).get("pullRequests") or {}).get("nodes") or []
|
|
235
|
+
since = (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
236
|
+
prs = [p for p in nodes if p and (p.get("createdAt") or "") >= since
|
|
237
|
+
and kind_of((p.get("author") or {}).get("login"), (p.get("author") or {}).get("__typename")) == "person"]
|
|
238
|
+
if len(prs) < min_prs:
|
|
239
|
+
return None
|
|
240
|
+
people = People(_review_records(prs))
|
|
241
|
+
waits, reviewers = [], {}
|
|
242
|
+
for p in prs:
|
|
243
|
+
author = (p.get("author") or {}).get("login")
|
|
244
|
+
for r in (p.get("reviews") or {}).get("nodes") or []:
|
|
245
|
+
who = (r or {}).get("author") and r["author"].get("login")
|
|
246
|
+
if who and who != author and people.is_insider(who):
|
|
247
|
+
reviewers[who] = reviewers.get(who, 0) + 1
|
|
248
|
+
first = min((r["submittedAt"] for r in (p.get("reviews") or {}).get("nodes") or []
|
|
249
|
+
if r and r.get("submittedAt") and people.is_insider((r.get("author") or {}).get("login"))
|
|
250
|
+
and (r.get("author") or {}).get("login") != author), default=None)
|
|
251
|
+
if first:
|
|
252
|
+
waits.append((datetime.fromisoformat(first.replace("Z", "+00:00"))
|
|
253
|
+
- datetime.fromisoformat(p["createdAt"].replace("Z", "+00:00"))).total_seconds())
|
|
254
|
+
waits.sort()
|
|
255
|
+
mid = len(waits) // 2
|
|
256
|
+
median = (waits[mid] if len(waits) % 2 else (waits[mid - 1] + waits[mid]) / 2) if waits else None
|
|
257
|
+
return {"first_review_median_s": median, "prs": len(prs),
|
|
258
|
+
"reviewers": [w for w, _ in sorted(reviewers.items(), key=lambda kv: (-kv[1], kv[0]))][:3]}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def branch_protected(api, repo: str, branch: str | None) -> bool | None:
|
|
262
|
+
# Classic branch protection isn't in the rules endpoint; whether it requires reviews needs an admin token.
|
|
263
|
+
body = read(api, f"/repos/{repo}/branches/{branch}") if branch else NA
|
|
264
|
+
return bool(body.get("protected")) if body is not NA and isinstance(body, dict) else None
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Who owns a repo, checked rather than just read: the CODEOWNERS file GitHub would use, GitHub's own error check, teams that
|
|
2
|
+
have members, and users who are still in the org. Without a file, recent committers are offered as suggestions only."""
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from stackvitals import codeowners
|
|
7
|
+
from stackvitals.portal.github import NA, read, read_file, status
|
|
8
|
+
|
|
9
|
+
# Commit authors that are not people: GitHub's placeholder for unlinked emails, web edits, deleted accounts.
|
|
10
|
+
PLACEHOLDERS = {"invalid-email-address", "web-flow", "ghost"}
|
|
11
|
+
CATCH_ALL = {"*", "/*", "/", "**", "/**"}
|
|
12
|
+
CODEOWNERS_PATHS = codeowners.PATHS
|
|
13
|
+
MAX_USER_CHECKS = 10
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
parse_codeowners = codeowners.parse
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def suggest(api, repo: str, now: datetime, days: int = 90) -> list[str]:
|
|
20
|
+
commits = read(api, f"/repos/{repo}/commits", {"since": (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
21
|
+
"per_page": 100})
|
|
22
|
+
if commits is NA or not isinstance(commits, list):
|
|
23
|
+
return []
|
|
24
|
+
counts = Counter(((c.get("author") or {}).get("login") for c in commits))
|
|
25
|
+
# Machine accounts are often plain users named like bots (zaniebot, astral-docs-bot): never suggested as owners.
|
|
26
|
+
return [who for who, _ in counts.most_common()
|
|
27
|
+
if who and who.lower() not in PLACEHOLDERS and not who.lower().endswith(("bot", "[bot]", "-bot"))][:3]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def owner_status(api, org: str, repo: dict, now: datetime, cache: dict, members_visible: bool = True,
|
|
31
|
+
reviewers: list[str] | None = None, pre: dict | None = None) -> dict:
|
|
32
|
+
"""valid / weak / none, with the reasons. Team sizes and memberships are cached for the whole run. In public mode
|
|
33
|
+
the token can't see private memberships, so users aren't checked (members_visible=False)."""
|
|
34
|
+
name, branch = repo["name"], repo.get("default_branch")
|
|
35
|
+
text = file = None
|
|
36
|
+
if pre:
|
|
37
|
+
file, text = pre["codeowners"]
|
|
38
|
+
for path in () if pre else CODEOWNERS_PATHS:
|
|
39
|
+
got = read_file(api, name, path, branch)
|
|
40
|
+
if got is not NA:
|
|
41
|
+
text, file = got, path
|
|
42
|
+
break
|
|
43
|
+
if text is None:
|
|
44
|
+
# People who review are better owner candidates than committers (machine accounts commit, e.g. charmcli).
|
|
45
|
+
return {"status": "none", "file": None, "reasons": ["no CODEOWNERS file"], "owners": [], "teams": {}, "notes": [],
|
|
46
|
+
"suggested": (reviewers or [])[:3] or suggest(api, name, now),
|
|
47
|
+
"suggested_from": "recent reviews" if reviewers else "recent commits"}
|
|
48
|
+
rules = parse_codeowners(text)
|
|
49
|
+
owners = sorted({o for _, os in rules for o in os})
|
|
50
|
+
reasons, teams, notes = [], {}, []
|
|
51
|
+
if not any(pattern in CATCH_ALL and os for pattern, os in rules):
|
|
52
|
+
reasons.append("no catch-all line (* or /): files outside the listed paths have no owner")
|
|
53
|
+
errors = read(api, f"/repos/{name}/codeowners/errors", {"ref": branch} if branch else None)
|
|
54
|
+
if errors is not NA and (errors or {}).get("errors"):
|
|
55
|
+
reasons.append(f"{len(errors['errors'])} errors in CODEOWNERS (GitHub's own check)")
|
|
56
|
+
users = 0
|
|
57
|
+
for owner in owners:
|
|
58
|
+
if owner.startswith("@") and "/" in owner:
|
|
59
|
+
team = owner[1:]
|
|
60
|
+
team_org, slug = team.split("/", 1)
|
|
61
|
+
if ("team", team) not in cache:
|
|
62
|
+
members = read(api, f"/orgs/{team_org}/teams/{slug}/members", {"per_page": 100})
|
|
63
|
+
cache[("team", team)] = NA if members is NA else len(members)
|
|
64
|
+
teams[team] = cache[("team", team)]
|
|
65
|
+
if teams[team] == 0:
|
|
66
|
+
reasons.append(f"team {team} has no members")
|
|
67
|
+
elif owner.startswith("@") and members_visible and users < MAX_USER_CHECKS:
|
|
68
|
+
user, users = owner[1:], users + 1
|
|
69
|
+
if ("member", user) not in cache:
|
|
70
|
+
cache[("member", user)] = status(api, f"/orgs/{org}/members/{user}")
|
|
71
|
+
if cache[("member", user)] == 404:
|
|
72
|
+
reasons.append(f"{user} is named as an owner but is not a member of {org}")
|
|
73
|
+
if any(v is NA for v in teams.values()):
|
|
74
|
+
notes.append("team members not visible to this token")
|
|
75
|
+
if not members_visible and any(o.startswith("@") and "/" not in o for o in owners):
|
|
76
|
+
notes.append("org membership of named users not checked (not visible to this token)")
|
|
77
|
+
return {"status": "weak" if reasons else "valid", "file": file, "reasons": reasons, "owners": owners, "notes": notes,
|
|
78
|
+
"teams": {k: (None if v is NA else v) for k, v in teams.items()}}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Markdown pages in the portal repo, which GitHub shows only to people who can read that repo. Private repos never reach
|
|
2
|
+
a page anyone else can open."""
|
|
3
|
+
from stackvitals.flow.report import _md
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def publishable(repos: list[dict], destination_private: bool) -> list[dict]:
|
|
7
|
+
# The guard: a public destination gets public repos only, whatever the token could read.
|
|
8
|
+
return list(repos) if destination_private else [r for r in repos if not r.get("private")]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _cell(text) -> str:
|
|
12
|
+
return _md(" ".join(str(text).split())).replace("@", "@")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _owners(r: dict) -> tuple[str, str]:
|
|
16
|
+
o = r.get("owners") or {}
|
|
17
|
+
why = "; ".join((o.get("reasons") or []) + (o.get("notes") or []))
|
|
18
|
+
if o.get("suggested"):
|
|
19
|
+
why += ("; " if why else "") + f"suggested from {o.get('suggested_from') or 'recent commits'}: " + ", ".join(o["suggested"])
|
|
20
|
+
return o.get("status") or "-", why
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
CATALOG_ROWS = 200
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def catalog_entities(active: list[dict]) -> tuple[list[dict], list[tuple[str, str]], int]:
|
|
27
|
+
"""Every entity the active repos' catalog-info.yaml files declare (with its repo), their problems, and how many
|
|
28
|
+
repos have a file."""
|
|
29
|
+
entities, problems, files = [], [], 0
|
|
30
|
+
for r in active:
|
|
31
|
+
d = r.get("descriptor") or {}
|
|
32
|
+
if d.get("present"):
|
|
33
|
+
files += 1
|
|
34
|
+
entities += [{**e, "repo": r["name"]} for e in d.get("entities") or []]
|
|
35
|
+
problems += [(r["name"], p) for p in d.get("problems") or []]
|
|
36
|
+
return entities, problems, files
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _owner_cell(e: dict) -> str:
|
|
40
|
+
check = e.get("owner_check") or []
|
|
41
|
+
note = {"pass": "", "fail": " (not in CODEOWNERS)"}.get(check[0] if check else "", "")
|
|
42
|
+
return f"{e.get('owner') or '-'}{note}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _catalog_lines(active: list[dict]) -> list[str]:
|
|
46
|
+
entities, problems, files = catalog_entities(active)
|
|
47
|
+
if not files:
|
|
48
|
+
return []
|
|
49
|
+
off = sum(1 for e in entities if (e.get("owner_check") or [""])[0] == "fail")
|
|
50
|
+
out = ["## Catalog", "", f"{len(entities)} entities from {files} of {len(active)} active repos' catalog-info.yaml files"
|
|
51
|
+
+ (f"; {off} owner{'' if off == 1 else 's'} not in CODEOWNERS" if off else "") + ".", "",
|
|
52
|
+
"| Entity | Kind | Type | Lifecycle | Owner | System | Repo |", "|---|---|---|---|---|---|---|"]
|
|
53
|
+
for e in entities[:CATALOG_ROWS]:
|
|
54
|
+
out.append(f"| {_cell(e['name'])} | {e['kind']} | {_cell(e.get('type') or '-')} | {_cell(e.get('lifecycle') or '-')} | "
|
|
55
|
+
f"{_cell(_owner_cell(e))} | {_cell(e.get('system') or '-')} | {_cell(e['repo'])} |")
|
|
56
|
+
if problems:
|
|
57
|
+
out += ["", "Problems: " + "; ".join(f"{_cell(repo)}: {_cell(p)}" for repo, p in problems[:20]) + "."]
|
|
58
|
+
return out + [""]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_catalog(catalog: dict, destination_private: bool) -> str:
|
|
62
|
+
"""The catalog page: owner status counts, then one row per active repo with the evidence."""
|
|
63
|
+
repos = publishable(catalog["repos"], destination_private)
|
|
64
|
+
active = [r for r in repos if not r.get("archived") and not r.get("fork")]
|
|
65
|
+
counts = {s: sum(1 for r in active if (r.get("owners") or {}).get("status") == s) for s in ("valid", "weak", "none")}
|
|
66
|
+
archived, forks = sum(1 for r in repos if r.get("archived")), sum(1 for r in repos if r.get("fork") and not r.get("archived"))
|
|
67
|
+
out = [f"# Portal: {_cell(catalog['org'])}", "",
|
|
68
|
+
f"{len(repos)} repos ({len(active)} active, {archived} archived, {forks} forks), updated {catalog['built_at'][:16]} UTC.",
|
|
69
|
+
"", f"Owners: {counts['valid']} valid, {counts['weak']} weak, {counts['none']} without a CODEOWNERS file.", ""]
|
|
70
|
+
if len(repos) < len(catalog["repos"]):
|
|
71
|
+
out += ["Private repos are left out of this page because the portal repo is public.", ""]
|
|
72
|
+
if not catalog.get("complete", True):
|
|
73
|
+
out += ["Some repos keep an earlier check (GitHub's hourly limit); the next run continues with them.", ""]
|
|
74
|
+
scored = [r for r in active if r.get("score")]
|
|
75
|
+
if scored:
|
|
76
|
+
n = {lvl: sum(1 for r in scored if r["score"]["level"] == lvl) for lvl in ("gold", "silver", "bronze", None)}
|
|
77
|
+
out += [f"Levels: {n['gold']} gold, {n['silver']} silver, {n['bronze']} bronze, {n[None]} below bronze.", ""]
|
|
78
|
+
failed = {}
|
|
79
|
+
for r in scored:
|
|
80
|
+
for res in r["score"]["results"]:
|
|
81
|
+
if res["result"] == "fail":
|
|
82
|
+
failed[res["id"]] = failed.get(res["id"], 0) + 1
|
|
83
|
+
if failed:
|
|
84
|
+
top = sorted(failed.items(), key=lambda kv: (-kv[1], kv[0]))[:5]
|
|
85
|
+
out += ["Most failed rules: " + ", ".join(f"{_cell(k)} ({v} repo{'' if v == 1 else 's'})" for k, v in top) + ".", ""]
|
|
86
|
+
for c in catalog.get("campaigns") or []:
|
|
87
|
+
left = f"; {c['days_left']} days left (until {c['until']})" if c.get("days_left") is not None else ""
|
|
88
|
+
out += [f"## Campaign: {_cell(c['title'])}", "",
|
|
89
|
+
f"{c['passing']} of {c['targets']} repos pass ({', '.join(_cell(r) for r in c['rules'])}){left}."]
|
|
90
|
+
if c["failing"]:
|
|
91
|
+
out.append("Still failing: " + ", ".join(_cell(n) for n in c["failing"][:30])
|
|
92
|
+
+ (f" and {len(c['failing']) - 30} more" if len(c["failing"]) > 30 else "") + ".")
|
|
93
|
+
if len(c["burn_down"]) > 1:
|
|
94
|
+
out.append("Failing over time: " + " → ".join(f"{d} {n}" for d, n in c["burn_down"][-8:]) + ".")
|
|
95
|
+
out.append("")
|
|
96
|
+
out += _catalog_lines(active)
|
|
97
|
+
out += ["## Repos", "", "| Repo | Level | Owners | Why | Language | Last push | CI first try | Community | Branch rules |",
|
|
98
|
+
"|---|---|---|---|---|---|---|---|---|"]
|
|
99
|
+
for r in active:
|
|
100
|
+
status, why = _owners(r)
|
|
101
|
+
m = r.get("modules") or {}
|
|
102
|
+
rules = r.get("rules")
|
|
103
|
+
level = ((r.get("score") or {}).get("level") or "below bronze") if r.get("score") else "-"
|
|
104
|
+
when = r.get("since") or {}
|
|
105
|
+
if when.get("owners") and status != "-":
|
|
106
|
+
status = f"{status} since {when['owners']}"
|
|
107
|
+
if when.get("level") and r.get("score"):
|
|
108
|
+
level = f"{level} since {when['level']}"
|
|
109
|
+
out.append(f"| {_cell(r['name'])} | {level} | {status} | {_cell(why) or '-'} | {_cell(r.get('language') or '-')} | "
|
|
110
|
+
f"{(r.get('pushed') or '-')[:10]} | {_cell(m.get('ci_first_try') or '-')} | {_cell(m.get('community_score') or '-')} | "
|
|
111
|
+
f"{len(rules) if isinstance(rules, list) else 'not available'} |")
|
|
112
|
+
return "\n".join(out) + "\n"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def render_catalog_html(catalog: dict, destination_private: bool, pages=()) -> str:
|
|
116
|
+
"""The same catalog as a page in the shared header (docs/portal.html), under the same guard as the Markdown."""
|
|
117
|
+
import html
|
|
118
|
+
|
|
119
|
+
from stackvitals import shell
|
|
120
|
+
e = html.escape
|
|
121
|
+
repos = publishable(catalog["repos"], destination_private)
|
|
122
|
+
active = [r for r in repos if not r.get("archived") and not r.get("fork")]
|
|
123
|
+
counts = {s: sum(1 for r in active if (r.get("owners") or {}).get("status") == s) for s in ("valid", "weak", "none")}
|
|
124
|
+
body = [f"<p>{len(repos)} repos ({len(active)} active). Owners: {counts['valid']} valid, {counts['weak']} weak, "
|
|
125
|
+
f"{counts['none']} without a CODEOWNERS file.</p>"]
|
|
126
|
+
if len(repos) < len(catalog["repos"]):
|
|
127
|
+
body.append("<p class=mut>Private repos are left out of this page because the portal repo is public.</p>")
|
|
128
|
+
for c in catalog.get("campaigns") or []:
|
|
129
|
+
body.append(f"<h2>Campaign: {e(c['title'])}</h2><p>{c['passing']} of {c['targets']} repos pass "
|
|
130
|
+
f"({e(', '.join(c['rules']))}).</p>")
|
|
131
|
+
entities, problems, files = catalog_entities(active)
|
|
132
|
+
if files:
|
|
133
|
+
body.append(f"<h2>Catalog</h2><p>{len(entities)} entities from {files} of {len(active)} active repos' catalog-info.yaml "
|
|
134
|
+
"files.</p><div class=tbl><table class=find><tr><th>Entity</th><th data-filter>Kind</th><th data-filter>Type</th>"
|
|
135
|
+
"<th data-filter>Lifecycle</th><th>Owner</th><th data-filter>System</th><th>Repo</th></tr>")
|
|
136
|
+
body += [f"<tr><td>{e(x['name'])}</td><td>{e(x['kind'])}</td><td>{e(x.get('type') or '-')}</td>"
|
|
137
|
+
f"<td>{e(x.get('lifecycle') or '-')}</td><td>{e(_owner_cell(x))}</td><td>{e(x.get('system') or '-')}</td>"
|
|
138
|
+
f"<td>{e(x['repo'])}</td></tr>" for x in entities[:CATALOG_ROWS]]
|
|
139
|
+
body.append("</table></div>")
|
|
140
|
+
if problems:
|
|
141
|
+
body.append("<p class=mut>Problems: " + "; ".join(f"{e(repo)}: {e(p)}" for repo, p in problems[:20]) + ".</p>")
|
|
142
|
+
body.append("<h2>Repos</h2><div class=tbl><table class=find><tr><th data-filter>Level</th><th>Repo</th>"
|
|
143
|
+
"<th data-filter>Owners</th><th>Why</th><th data-filter>Language</th><th>Last push</th><th>CI first try</th>"
|
|
144
|
+
"<th>Community</th></tr>")
|
|
145
|
+
for r in active:
|
|
146
|
+
status, why = _owners(r)
|
|
147
|
+
m = r.get("modules") or {}
|
|
148
|
+
level = ((r.get("score") or {}).get("level") or "below bronze") if r.get("score") else "-"
|
|
149
|
+
body.append(f"<tr><td>{e(level)}</td><td>{e(str(r['name']))}</td><td>{e(status)}</td><td class=mut>{e(why) or '-'}</td>"
|
|
150
|
+
f"<td>{e(str(r.get('language') or '-'))}</td><td>{e((r.get('pushed') or '-')[:10])}</td>"
|
|
151
|
+
f"<td>{e(str(m.get('ci_first_try') or '-'))}</td><td>{e(str(m.get('community_score') or '-'))}</td></tr>")
|
|
152
|
+
body.append("</table></div>")
|
|
153
|
+
return shell.page("Portal", catalog["org"], "portal.html", f"Updated {e(catalog['built_at'][:16])} UTC.", "".join(body), pages)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""The org view: every repo that runs the suite, rolled up from the facts each publishes on its own data branch (counts
|
|
2
|
+
summed, rates and times as medians across repos, and the repos that lead each), so a platform team sees the org's
|
|
3
|
+
CI, review and delivery health on one page without reading any repo's raw history."""
|
|
4
|
+
from statistics import median
|
|
5
|
+
|
|
6
|
+
SUMS = ("ci_flaky", "ci_broken", "open_prs", "stale_prs", "deployments", "findings", "newcomers_waiting", "vulns_open",
|
|
7
|
+
"vulns_serious")
|
|
8
|
+
MEDIANS = ("ci_first_try", "pickup_p75_h", "review_p75_h", "cycle_p75_h", "change_failure_rate", "community_score")
|
|
9
|
+
# Which way is worse for each number the leaders are ranked by.
|
|
10
|
+
WORSE_HIGH = {"ci_flaky", "ci_broken", "stale_prs", "findings", "newcomers_waiting", "pickup_p75_h", "review_p75_h",
|
|
11
|
+
"cycle_p75_h", "change_failure_rate", "vulns_serious"}
|
|
12
|
+
LEADERS = 5
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _num(v) -> bool:
|
|
16
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def org_rollup(repos: list[dict]) -> dict:
|
|
20
|
+
live = [r for r in repos if not r.get("archived") and not r.get("fork")]
|
|
21
|
+
facts = {r["name"]: ((r.get("modules") or {}).get("facts") or {}) for r in live if (r.get("modules") or {}).get("facts")}
|
|
22
|
+
out = {"repos": len(live), "running": len(facts), "totals": {}, "medians": {}, "counts": {}, "leaders": {}}
|
|
23
|
+
for key in SUMS:
|
|
24
|
+
vals = [f[key] for f in facts.values() if _num(f.get(key))]
|
|
25
|
+
out["totals"][key] = sum(vals) if vals else None
|
|
26
|
+
for key in MEDIANS:
|
|
27
|
+
vals = [f[key] for f in facts.values() if _num(f.get(key))]
|
|
28
|
+
out["medians"][key], out["counts"][key] = (round(median(vals), 4) if vals else None), len(vals)
|
|
29
|
+
for key in WORSE_HIGH:
|
|
30
|
+
ranked = sorted(((n, f[key]) for n, f in facts.items() if _num(f.get(key)) and f[key] > 0), key=lambda x: -x[1])
|
|
31
|
+
out["leaders"][key] = [{"repo": n, "value": v} for n, v in ranked[:LEADERS]]
|
|
32
|
+
return out
|