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,79 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mergescout.github.graphql import GraphQLError
|
|
8
|
+
from stackvitals.ci.api import ActionsAPI, APIError, OutOfBudget
|
|
9
|
+
from stackvitals.ci.cli import home, token
|
|
10
|
+
from stackvitals.community.action import load_benchmarks, write_community_outputs
|
|
11
|
+
from stackvitals.community.build import build
|
|
12
|
+
from stackvitals.community.collect import _read, collect_issues, collect_stars, collect_traffic
|
|
13
|
+
from stackvitals.community.report import render_text
|
|
14
|
+
from stackvitals.flow.cli import local_gql
|
|
15
|
+
from stackvitals.flow.collect import collect
|
|
16
|
+
from stackvitals.flow.store import FlowStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def local_community(repo: str, tok: str, now: datetime, months: int = 12, make_gql=local_gql, make_api=ActionsAPI,
|
|
20
|
+
root: Path | None = None, log=None) -> tuple[dict, dict]:
|
|
21
|
+
"""Read a repo's PRs (shared with Team Flow's local copy), issues, star history and, if this token may, traffic."""
|
|
22
|
+
owner, name = repo.split("/", 1)
|
|
23
|
+
root = Path(root or home())
|
|
24
|
+
prs = FlowStore(root / "flow" / repo.replace("/", "__"))
|
|
25
|
+
folder = root / "community" / repo.replace("/", "__")
|
|
26
|
+
issues = FlowStore(folder, "issues")
|
|
27
|
+
gql = make_gql(tok)
|
|
28
|
+
got_prs = collect(gql, prs, owner, name, now, months=months, log=log)
|
|
29
|
+
got = collect_issues(gql, issues, owner, name, now, months=months)
|
|
30
|
+
notes = []
|
|
31
|
+
try:
|
|
32
|
+
collect_stars(make_api(tok, repo, max_requests=110), folder, now)
|
|
33
|
+
except (APIError, OutOfBudget) as exc:
|
|
34
|
+
notes.append(f"Star history wasn't read ({exc}).")
|
|
35
|
+
try:
|
|
36
|
+
# Only a token with Administration read on this repo (yours, if you own it) may read traffic.
|
|
37
|
+
collect_traffic(make_api(tok, repo, max_requests=10), folder, now)
|
|
38
|
+
except (APIError, OutOfBudget):
|
|
39
|
+
pass
|
|
40
|
+
bench, star_bench = load_benchmarks(root / "bench", tok)
|
|
41
|
+
result = build(prs.load(), issues.load(), _read(folder / "stars.json"), _read(folder / "traffic.json"), prs.repo(), now,
|
|
42
|
+
bench=bench, star_bench=star_bench)
|
|
43
|
+
return result, {"complete": got_prs["complete"] and got["complete"], "issues_refused": bool(got.get("refused")),
|
|
44
|
+
"notes": notes}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main(argv: list[str] | None = None, now: datetime | None = None, make_gql=local_gql, make_api=ActionsAPI,
|
|
48
|
+
tok: str | None = None) -> int:
|
|
49
|
+
parser = argparse.ArgumentParser(prog="stackvitals community",
|
|
50
|
+
description="Community: where newcomers drop out and how fast they get a first answer.")
|
|
51
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
52
|
+
rep = sub.add_parser("report", help="Read a repo's PRs, issues and stars and show its newcomer funnel and first answers")
|
|
53
|
+
rep.add_argument("repo", help="owner/name")
|
|
54
|
+
rep.add_argument("--months", type=int, default=12, help="history to read on the first run")
|
|
55
|
+
rep.add_argument("--json", action="store_true")
|
|
56
|
+
rep.add_argument("--html", help="also write the page and badges into this folder")
|
|
57
|
+
args = parser.parse_args(argv)
|
|
58
|
+
tok = tok or token()
|
|
59
|
+
if not tok:
|
|
60
|
+
print("A GitHub token is needed. Set GITHUB_TOKEN, or run `gh auth login`.", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
now = now or datetime.now(timezone.utc)
|
|
63
|
+
try:
|
|
64
|
+
result, stats = local_community(args.repo, tok, now, months=args.months, make_gql=make_gql, make_api=make_api,
|
|
65
|
+
log=lambda line: print(line, file=sys.stderr, flush=True))
|
|
66
|
+
except GraphQLError as exc:
|
|
67
|
+
print(f"GitHub refused: {exc}", file=sys.stderr)
|
|
68
|
+
return 1
|
|
69
|
+
if args.html:
|
|
70
|
+
write_community_outputs(Path(args.html), result, args.repo)
|
|
71
|
+
if args.json:
|
|
72
|
+
print(json.dumps({**result, "collect": stats}, indent=1))
|
|
73
|
+
return 0
|
|
74
|
+
print(render_text(result, args.repo))
|
|
75
|
+
for note in stats["notes"]:
|
|
76
|
+
print(f"\n({note})")
|
|
77
|
+
if not stats["complete"]:
|
|
78
|
+
print("\n(Not all pull requests and issues were read yet; run the same command again to continue.)")
|
|
79
|
+
return 0
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""What Community adds to Team Flow's PR history: issues with their first replies, the weekly star history and, when the
|
|
2
|
+
user set up a token that may read it, traffic (GitHub deletes it after 14 days)."""
|
|
3
|
+
import json
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from mergescout.github.graphql import GraphQLError, RateLimited
|
|
9
|
+
from stackvitals.flow.store import FlowStore
|
|
10
|
+
|
|
11
|
+
ISSUES_QUERY = """
|
|
12
|
+
query($owner:String!,$name:String!,$cursor:String,$n:Int!,$since:DateTime) {
|
|
13
|
+
rateLimit { cost remaining resetAt }
|
|
14
|
+
repository(owner:$owner,name:$name) {
|
|
15
|
+
issues(first:$n, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}, filterBy:{since:$since}) {
|
|
16
|
+
pageInfo { hasNextPage endCursor }
|
|
17
|
+
nodes {
|
|
18
|
+
number state createdAt updatedAt closedAt author { __typename login } authorAssociation
|
|
19
|
+
labels(first:10) { nodes { name } }
|
|
20
|
+
comments(first:10) { totalCount nodes { createdAt author { __typename login } authorAssociation } }
|
|
21
|
+
closer: timelineItems(last:1, itemTypes:[CLOSED_EVENT]) { nodes { ... on ClosedEvent { createdAt actor { login } } } }
|
|
22
|
+
triage: timelineItems(first:5, itemTypes:[LABELED_EVENT, ASSIGNED_EVENT, MARKED_AS_DUPLICATE_EVENT]) {
|
|
23
|
+
nodes { ... on LabeledEvent { createdAt actor { __typename login } } ... on AssignedEvent { createdAt actor { __typename login } }
|
|
24
|
+
... on MarkedAsDuplicateEvent { createdAt actor { __typename login } } } }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}"""
|
|
29
|
+
SCHEMA = 3 # 3: who triaged each issue (labels, assignments, duplicates)
|
|
30
|
+
OVERLAP = timedelta(hours=1)
|
|
31
|
+
# Discussions: questions answered there instead of in issues (no `since` filter, so pages stop at the last read).
|
|
32
|
+
DISCUSSIONS_QUERY = """
|
|
33
|
+
query($owner:String!,$name:String!,$cursor:String,$n:Int!) {
|
|
34
|
+
rateLimit { cost remaining resetAt }
|
|
35
|
+
repository(owner:$owner,name:$name) {
|
|
36
|
+
discussions(first:$n, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}) {
|
|
37
|
+
pageInfo { hasNextPage endCursor }
|
|
38
|
+
nodes {
|
|
39
|
+
number createdAt updatedAt closed closedAt answerChosenAt author { __typename login } authorAssociation
|
|
40
|
+
category { name isAnswerable }
|
|
41
|
+
comments(first:10) { totalCount nodes { createdAt author { __typename login } authorAssociation } }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def issue_record(node: dict, now: datetime) -> dict:
|
|
49
|
+
"""One issue as a compact record: who opened it and when the first replies came (their text is never read)."""
|
|
50
|
+
author = node.get("author") or {}
|
|
51
|
+
talk = [[c.get("createdAt"), (c.get("author") or {}).get("login"), c.get("authorAssociation"),
|
|
52
|
+
(c.get("author") or {}).get("__typename")]
|
|
53
|
+
for c in (node.get("comments") or {}).get("nodes") or [] if c and c.get("createdAt")]
|
|
54
|
+
return {"t": "issue", "n": node["number"], "st": node.get("state"), "cr": node.get("createdAt"),
|
|
55
|
+
"up": node.get("updatedAt"), "cl": node.get("closedAt"), "au": author.get("login"), "at": author.get("__typename"),
|
|
56
|
+
"as": node.get("authorAssociation"), "cm": sorted(talk, key=lambda c: c[0]),
|
|
57
|
+
"cmn": (node.get("comments") or {}).get("totalCount"),
|
|
58
|
+
# Who closed it: an author closing their own issue is not an answer.
|
|
59
|
+
"clb": (((((node.get("closer") or {}).get("nodes") or [None])[-1]) or {}).get("actor") or {}).get("login"),
|
|
60
|
+
# Who labelled, assigned or marked it a duplicate: on GitHub only people with triage rights can, on others' issues.
|
|
61
|
+
"tr": sorted({((e.get("actor") or {}).get("login"), e.get("createdAt")) for e in ((node.get("triage") or {}).get("nodes") or [])
|
|
62
|
+
if e and e.get("createdAt") and (e.get("actor") or {}).get("__typename") == "User"
|
|
63
|
+
and (e.get("actor") or {}).get("login") not in (None, author.get("login"))}),
|
|
64
|
+
# Labels say which issues are incidents (Delivery's change failure rate).
|
|
65
|
+
"lb": [x["name"] for x in (node.get("labels") or {}).get("nodes") or [] if x and x.get("name")],
|
|
66
|
+
"f": now.strftime("%Y-%m-%dT%H:%M:%SZ")}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def discussion_record(node: dict, now: datetime) -> dict:
|
|
70
|
+
"""A discussion kept like an issue (the same answer rule reads both), with its category and when an answer was chosen."""
|
|
71
|
+
rec = issue_record({**node, "state": "CLOSED" if node.get("closed") else "OPEN"}, now)
|
|
72
|
+
cat = node.get("category") or {}
|
|
73
|
+
return {**rec, "t": "discussion", "cat": cat.get("name"), "qa": bool(cat.get("isAnswerable")), "ans": node.get("answerChosenAt")}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def collect_discussions(gql, store: FlowStore, owner: str, name: str, now: datetime, months: int = 12, page: int = 50,
|
|
77
|
+
max_pages: int = 40) -> dict:
|
|
78
|
+
"""Discussions updated since the last run (or the last `months`), newest first, stopping at what was already read."""
|
|
79
|
+
state = store.state()
|
|
80
|
+
floor = (datetime.fromisoformat(state["synced_up_to"].replace("Z", "+00:00")) - OVERLAP if state.get("synced_up_to")
|
|
81
|
+
else now - timedelta(days=30 * months)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
82
|
+
opened, closed = store.open(), defaultdict(list)
|
|
83
|
+
cursor, newest, count, cost, complete, pages = None, None, 0, 0, True, 0
|
|
84
|
+
while pages < max_pages:
|
|
85
|
+
try:
|
|
86
|
+
result = gql.query(DISCUSSIONS_QUERY, {"owner": owner, "name": name, "cursor": cursor, "n": page})
|
|
87
|
+
except RateLimited:
|
|
88
|
+
complete = False
|
|
89
|
+
break
|
|
90
|
+
except GraphQLError as exc:
|
|
91
|
+
# Discussions off, or the token can't read them: nothing to keep, and the run goes on.
|
|
92
|
+
return {"discussions": 0, "points": cost, "complete": False, "refused": "not accessible" in str(exc).lower()}
|
|
93
|
+
pages += 1
|
|
94
|
+
cost += result.cost
|
|
95
|
+
block = (result.data.get("repository") or {}).get("discussions") or {"nodes": [], "pageInfo": {}}
|
|
96
|
+
stop = False
|
|
97
|
+
for node in block["nodes"]:
|
|
98
|
+
if not node:
|
|
99
|
+
continue
|
|
100
|
+
if node["updatedAt"] < floor:
|
|
101
|
+
stop = True
|
|
102
|
+
break
|
|
103
|
+
newest = newest or node["updatedAt"]
|
|
104
|
+
rec = discussion_record(node, now)
|
|
105
|
+
count += 1
|
|
106
|
+
if rec["st"] == "OPEN":
|
|
107
|
+
opened[rec["n"]] = rec
|
|
108
|
+
else:
|
|
109
|
+
opened.pop(rec["n"], None)
|
|
110
|
+
closed[(rec["cl"] or rec["up"])[:10]].append(rec)
|
|
111
|
+
if stop or not (block.get("pageInfo") or {}).get("hasNextPage"):
|
|
112
|
+
break
|
|
113
|
+
cursor = block["pageInfo"]["endCursor"]
|
|
114
|
+
else:
|
|
115
|
+
complete = False
|
|
116
|
+
for day, recs in closed.items():
|
|
117
|
+
store.put(recs, day)
|
|
118
|
+
store.save_open(opened)
|
|
119
|
+
if complete:
|
|
120
|
+
state["synced_up_to"] = max(filter(None, [newest, state.get("synced_up_to")]), default=None)
|
|
121
|
+
store.save_state(state)
|
|
122
|
+
return {"discussions": count, "points": cost, "complete": complete}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def collect_issues(gql, store: FlowStore, owner: str, name: str, now: datetime, months: int = 12, page: int = 50,
|
|
126
|
+
max_pages: int = 200) -> dict:
|
|
127
|
+
"""Issues updated since the last run (or the last `months` on the first), about 1 point per 50 issues."""
|
|
128
|
+
state = store.state()
|
|
129
|
+
synced = state.get("synced_up_to") if state.get("schema", 0) >= SCHEMA else None
|
|
130
|
+
floor = (datetime.fromisoformat(synced.replace("Z", "+00:00")) - OVERLAP if synced
|
|
131
|
+
else now - timedelta(days=30 * months)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
132
|
+
opened, closed = store.open(), defaultdict(list)
|
|
133
|
+
cursor, newest, count, cost, complete, pages = None, None, 0, 0, True, 0
|
|
134
|
+
while pages < max_pages:
|
|
135
|
+
try:
|
|
136
|
+
result = gql.query(ISSUES_QUERY, {"owner": owner, "name": name, "cursor": cursor, "n": page, "since": floor})
|
|
137
|
+
except RateLimited:
|
|
138
|
+
complete = False
|
|
139
|
+
break
|
|
140
|
+
except GraphQLError as exc:
|
|
141
|
+
text = str(exc).lower()
|
|
142
|
+
# The workflow lacks `issues: read`: said in the summary, and the rest of the run goes on.
|
|
143
|
+
if "not accessible" in text:
|
|
144
|
+
return {"issues": 0, "points": cost, "complete": False, "refused": True}
|
|
145
|
+
# Issues turned off in this repo: nothing to read.
|
|
146
|
+
if "disabled" in text:
|
|
147
|
+
break
|
|
148
|
+
raise
|
|
149
|
+
pages += 1
|
|
150
|
+
cost += result.cost
|
|
151
|
+
block = (result.data.get("repository") or {}).get("issues") or {"nodes": [], "pageInfo": {}}
|
|
152
|
+
stop = False
|
|
153
|
+
for node in block["nodes"]:
|
|
154
|
+
if not node:
|
|
155
|
+
continue
|
|
156
|
+
# The filter keeps older issues out; this guards against GitHub returning one anyway.
|
|
157
|
+
if node["updatedAt"] < floor:
|
|
158
|
+
stop = True
|
|
159
|
+
break
|
|
160
|
+
newest = newest or node["updatedAt"]
|
|
161
|
+
rec = issue_record(node, now)
|
|
162
|
+
count += 1
|
|
163
|
+
if rec["st"] == "OPEN":
|
|
164
|
+
opened[rec["n"]] = rec
|
|
165
|
+
else:
|
|
166
|
+
opened.pop(rec["n"], None)
|
|
167
|
+
closed[(rec["cl"] or rec["up"])[:10]].append(rec)
|
|
168
|
+
if stop or not (block.get("pageInfo") or {}).get("hasNextPage"):
|
|
169
|
+
break
|
|
170
|
+
cursor = block["pageInfo"]["endCursor"]
|
|
171
|
+
else:
|
|
172
|
+
complete = False
|
|
173
|
+
for day, recs in closed.items():
|
|
174
|
+
store.put(recs, day)
|
|
175
|
+
store.save_open(opened)
|
|
176
|
+
if complete:
|
|
177
|
+
state["synced_up_to"] = max(filter(None, [newest, synced]), default=None)
|
|
178
|
+
state["schema"] = SCHEMA
|
|
179
|
+
state["last_collect"] = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
180
|
+
store.save_state(state)
|
|
181
|
+
store.compress(now.date().isoformat())
|
|
182
|
+
return {"issues": count, "points": cost, "complete": complete}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _read(path: Path) -> dict:
|
|
186
|
+
try:
|
|
187
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
188
|
+
except (OSError, ValueError):
|
|
189
|
+
return {}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _write(path: Path, data: dict) -> None:
|
|
193
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
path.write_text(json.dumps(data, sort_keys=True, separators=(",", ":")), encoding="utf-8", newline="\n")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def collect_stars(api, folder: Path, now: datetime, max_pages: int = 100) -> dict:
|
|
198
|
+
"""GitHub's weekly star counts (no stargazer names): all of it the first time, then the newest 30 weeks once a day."""
|
|
199
|
+
path = Path(folder) / "stars.json"
|
|
200
|
+
old = _read(path)
|
|
201
|
+
weeks = {str(k): v for k, v in (old.get("weeks") or {}).items()}
|
|
202
|
+
if (old.get("fetched") or "")[:10] == now.strftime("%Y-%m-%d"):
|
|
203
|
+
return {"requests": 0, "weeks": len(weeks)}
|
|
204
|
+
requests = 0
|
|
205
|
+
for page in range(1, (1 if weeks else max_pages) + 1):
|
|
206
|
+
rows = api._get(f"/repos/{api.repo}/stargazers/history", {"per_page": 30, "page": page})
|
|
207
|
+
requests += 1
|
|
208
|
+
if not isinstance(rows, list) or not rows:
|
|
209
|
+
break
|
|
210
|
+
for r in rows:
|
|
211
|
+
if isinstance(r, dict) and isinstance(r.get("week"), int):
|
|
212
|
+
weeks[str(r["week"])] = int(r.get("total") or 0)
|
|
213
|
+
if len(rows) < 30:
|
|
214
|
+
break
|
|
215
|
+
_write(path, {"weeks": weeks, "fetched": now.strftime("%Y-%m-%dT%H:%M:%SZ")})
|
|
216
|
+
return {"requests": requests, "weeks": len(weeks)}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def collect_traffic(api, folder: Path, now: datetime, keep_days: int = 400) -> dict:
|
|
220
|
+
"""Views and clones per day kept past GitHub's 14 days; the top referrers and paths as a dated snapshot.
|
|
221
|
+
Needs a token with Administration read (the user's own GitHub App), which the built-in token can't have."""
|
|
222
|
+
path = Path(folder) / "traffic.json"
|
|
223
|
+
out = {k: dict(_read(path).get(k) or {}) for k in ("views", "clones", "referrers", "paths")}
|
|
224
|
+
for key in ("views", "clones"):
|
|
225
|
+
body = api._get(f"/repos/{api.repo}/traffic/{key}", {"per": "day"})
|
|
226
|
+
for x in body.get(key) or []:
|
|
227
|
+
if isinstance(x, dict) and x.get("timestamp"):
|
|
228
|
+
# The newest answer wins: today's count grows during the day.
|
|
229
|
+
out[key][x["timestamp"][:10]] = [int(x.get("count") or 0), int(x.get("uniques") or 0)]
|
|
230
|
+
day = now.strftime("%Y-%m-%d")
|
|
231
|
+
refs = api._get(f"/repos/{api.repo}/traffic/popular/referrers")
|
|
232
|
+
out["referrers"][day] = [[r.get("referrer"), r.get("count"), r.get("uniques")] for r in refs or [] if isinstance(r, dict)][:10]
|
|
233
|
+
# Page titles are text anyone can set; only the paths are kept.
|
|
234
|
+
paths = api._get(f"/repos/{api.repo}/traffic/popular/paths")
|
|
235
|
+
out["paths"][day] = [[p.get("path"), p.get("count"), p.get("uniques")] for p in paths or [] if isinstance(p, dict)][:10]
|
|
236
|
+
oldest = (now - timedelta(days=keep_days)).strftime("%Y-%m-%d")
|
|
237
|
+
for key in ("referrers", "paths"):
|
|
238
|
+
out[key] = {d: v for d, v in out[key].items() if d >= oldest}
|
|
239
|
+
_write(path, out)
|
|
240
|
+
return {"requests": 4, "days": len(out["views"])}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""What a repo states about itself, read once a week and shown beside its numbers: a governance file, funding links, a
|
|
2
|
+
security policy, a code of conduct, Discussions, how many maintainers it lists, and any response time its contributing
|
|
3
|
+
guide promises (so a slow first answer is read against the project's own word). File text is read, never kept."""
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from mergescout.github.graphql import GraphQLError, RateLimited
|
|
7
|
+
|
|
8
|
+
PATHS = {"gov": ("GOVERNANCE.md", ".github/GOVERNANCE.md", "docs/GOVERNANCE.md"),
|
|
9
|
+
"c": ("CONTRIBUTING.md", ".github/CONTRIBUTING.md", "docs/CONTRIBUTING.md"),
|
|
10
|
+
"m": ("MAINTAINERS", "MAINTAINERS.md"), "o": (".github/CODEOWNERS", "CODEOWNERS")}
|
|
11
|
+
UNITS = {"hour": 1, "day": 24, "week": 168}
|
|
12
|
+
# "respond ... within 3 business days", "triage issues within 48 hours": a promise about people answering.
|
|
13
|
+
PROMISE = re.compile(r"(?i)\b(respond|reply|replies|response|triage|review|get back|acknowledge|answer)\w*\b[^.\n]{0,80}?"
|
|
14
|
+
r"\b((?:within|in)\s+(\d+)\s*(?:business\s+|working\s+)?(hour|day|week)s?)")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def stated_response(text: str | None) -> dict | None:
|
|
18
|
+
m = PROMISE.search(text or "")
|
|
19
|
+
if not m:
|
|
20
|
+
return None
|
|
21
|
+
return {"hours": int(m.group(3)) * UNITS[m.group(4).lower()], "said": " ".join(m.group(2).lower().split())}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def query() -> str:
|
|
25
|
+
files = " ".join(f'{k}{i}: object(expression: "HEAD:{p}") {{ ... on Blob {{ byteSize text }} }}'
|
|
26
|
+
for k, paths in PATHS.items() for i, p in enumerate(paths, 1))
|
|
27
|
+
return ("query($owner: String!, $name: String!) { rateLimit { cost remaining resetAt } repository(owner: $owner, name: $name) { isSecurityPolicyEnabled "
|
|
28
|
+
"hasDiscussionsEnabled codeOfConduct { name } fundingLinks { platform url } " + files + " } }")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _first(repo: dict, k: str) -> dict | None:
|
|
32
|
+
return next((repo.get(f"{k}{i}") for i in range(1, len(PATHS[k]) + 1) if repo.get(f"{k}{i}")), None)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def context_record(repo: dict) -> dict:
|
|
36
|
+
"""The facts, from one GraphQL answer: what is stated, never the files' text."""
|
|
37
|
+
contributing = (_first(repo, "c") or {}).get("text")
|
|
38
|
+
listed = [x for x in ((_first(repo, "m") or {}).get("text") or "").splitlines() if x.strip() and not x.lstrip().startswith("#")]
|
|
39
|
+
owners = {o for line in ((_first(repo, "o") or {}).get("text") or "").splitlines() if not line.lstrip().startswith("#")
|
|
40
|
+
for o in line.split()[1:] if o.startswith("@")}
|
|
41
|
+
return {"governance": bool(_first(repo, "gov")), "security_policy": bool(repo.get("isSecurityPolicyEnabled")),
|
|
42
|
+
"code_of_conduct": (repo.get("codeOfConduct") or {}).get("name"),
|
|
43
|
+
"funding": [f["platform"] for f in repo.get("fundingLinks") or [] if f and f.get("platform")],
|
|
44
|
+
"discussions": bool(repo.get("hasDiscussionsEnabled")), "contributing": bool(contributing),
|
|
45
|
+
"response_policy": stated_response(contributing),
|
|
46
|
+
"maintainers_listed": len(listed) or None, "code_owners": len(owners) or None}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_context(gql, owner: str, name: str) -> dict | None:
|
|
50
|
+
try:
|
|
51
|
+
repo = (gql.query(query(), {"owner": owner, "name": name}).data or {}).get("repository")
|
|
52
|
+
except (GraphQLError, RateLimited):
|
|
53
|
+
return None
|
|
54
|
+
return context_record(repo) if repo else None
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import html
|
|
2
|
+
|
|
3
|
+
from stackvitals import shell
|
|
4
|
+
from stackvitals.flow.report import _md
|
|
5
|
+
|
|
6
|
+
TRAFFIC_HELP = ("Traffic is off: GitHub keeps views and clones for 14 days only, and the built-in token can't read them. "
|
|
7
|
+
"To keep them, give the Action a token from your own GitHub App (see docs/modules.md).")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _when(stamp: str) -> str:
|
|
12
|
+
# ISO time as people read it; older files already hold "YYYY-MM-DD HH:MM UTC".
|
|
13
|
+
return stamp if stamp.endswith("UTC") else stamp[:16].replace("T", " ") + " UTC"
|
|
14
|
+
|
|
15
|
+
def _h(hours) -> str:
|
|
16
|
+
if hours is None:
|
|
17
|
+
return "-"
|
|
18
|
+
if hours < 1:
|
|
19
|
+
return "under 1 h"
|
|
20
|
+
return f"{round(hours)} h" if hours < 48 else f"{hours / 24:.1f} d"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _days(n: int) -> str:
|
|
24
|
+
return f"{n} day" if n == 1 else f"{n} days"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _pct(x, digits: int = 0) -> str:
|
|
28
|
+
return "-" if x is None else f"{x:.{digits}%}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _value(f: dict) -> str:
|
|
32
|
+
return _h(f["you"]) if f["kind"] == "hours" else _pct(f["you"])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _typical(f: dict) -> str:
|
|
36
|
+
return _h(f["typical"]) if f["kind"] == "hours" else _pct(f["typical"])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _sections(r: dict) -> list[tuple[str, list[str]]]:
|
|
40
|
+
"""Titled groups of plain-text lines, shared by the terminal, the job summary and the page (the waiting list apart)."""
|
|
41
|
+
out = []
|
|
42
|
+
s = r.get("score")
|
|
43
|
+
if s:
|
|
44
|
+
lines = [f"Community score {s['score']}/100: on average better than {s['score']}% of {s['group']} "
|
|
45
|
+
f"({s['repos']} repos) on {len(s['facts'])} facts."]
|
|
46
|
+
lines += [f"{f['name']}: {_value(f)} (typical {_typical(f)}), better than about {f['better_than']}%." for f in s["facts"]]
|
|
47
|
+
if s["left_out"]:
|
|
48
|
+
lines.append("Not scored yet (too little data here): " + ", ".join(k.replace("_", " ") for k in s["left_out"]) + ".")
|
|
49
|
+
out.append(("Score", lines))
|
|
50
|
+
elif not r.get("has_bench"):
|
|
51
|
+
out.append(("Score", ["No score yet: the public benchmarks aren't available to this run yet."]))
|
|
52
|
+
else:
|
|
53
|
+
out.append(("Score", ["No score yet: it needs two of these facts, and there is too little history here: 10+ outside PRs "
|
|
54
|
+
"in 90 days, 10+ newcomers two weeks old, 10+ newcomers four months old."]))
|
|
55
|
+
f = r["funnel"]
|
|
56
|
+
out.append(("Newcomers, last 12 months", [
|
|
57
|
+
f"{f['newcomers']} first-time contributors (a first PR or issue here).",
|
|
58
|
+
(f"Of the {f['judged']} whose first contribution was 90+ days ago, {f['returned']} came back within 90 days "
|
|
59
|
+
f"({_pct(f['return_rate'])})." if f["judged"] else "None is old enough yet to tell whether they came back."),
|
|
60
|
+
f"Regulars (active in 3 of the last 4 quarters): {f['regulars']}. Maintainers active: {f['maintainers']}."]))
|
|
61
|
+
a = r["answers"]
|
|
62
|
+
lines = []
|
|
63
|
+
for key, label in (("prs", "Outside PRs"), ("issues", "Outside issues")):
|
|
64
|
+
x = a[key]
|
|
65
|
+
if not x["items"] and x.get("earlier"):
|
|
66
|
+
lines.append(f"No {label.lower()} in the last {a['days']} days ({x['earlier']} in the 9 months before).")
|
|
67
|
+
elif x["items"] and x["median_hours"] is None:
|
|
68
|
+
lines.append(f"{label}: {x['items']}, none answered yet.")
|
|
69
|
+
elif x["items"]:
|
|
70
|
+
km = x.get("km")
|
|
71
|
+
if km and km.get("waiting") and not km.get("too_few"):
|
|
72
|
+
# The median of answered items alone hides the ones still waiting: say both.
|
|
73
|
+
honest = (f"over {_h(km['at_least'])} (more than half have no answer yet)" if km["median"] is None
|
|
74
|
+
else f"{_h(km['median'])} (likely {_h(km['low'])} "
|
|
75
|
+
+ (f"to {_h(km['high'])})" if km["high"] is not None else "or more)"))
|
|
76
|
+
lines.append(f"{label}: {x['items']}, answered {_pct(x['answered'])}, first answer after "
|
|
77
|
+
f"{_h(x['median_hours'])} among those answered, within a day {_pct(x['within_1d'])}; counting "
|
|
78
|
+
f"the {km['waiting']} still waiting, {honest}.")
|
|
79
|
+
else:
|
|
80
|
+
lines.append(f"{label}: {x['items']}, answered {_pct(x['answered'])}, first answer after {_h(x['median_hours'])} "
|
|
81
|
+
f"(median), within a day {_pct(x['within_1d'])}.")
|
|
82
|
+
if a.get("left_out"):
|
|
83
|
+
n = a["left_out"]["accounts"]
|
|
84
|
+
lines.append(f"Left out: {a['left_out']['items']} items from {n} account{'' if n == 1 else 's'} that opened 20 or more "
|
|
85
|
+
"in one day (likely automated).")
|
|
86
|
+
for key, label in (("newcomers", "Newcomers (their first contribution here)"), ("regulars", "Regulars")):
|
|
87
|
+
x = (r.get("tenure") or {}).get(key)
|
|
88
|
+
if x and x["n"]:
|
|
89
|
+
lines.append(f"{label}: {x['n']}, " + (f"first answer after {_h(x['median_h'])} (median), within a day "
|
|
90
|
+
f"{_pct(x['answered_1d_share'])}." if x["median_h"] is not None
|
|
91
|
+
else "too few to say (10 needed)."))
|
|
92
|
+
n = (a.get("issues") or {}).get("responders")
|
|
93
|
+
if lines and n:
|
|
94
|
+
lines.append(f"Issues count a reply from a maintainer or one of {n} regular responder{'' if n == 1 else 's'} (people who "
|
|
95
|
+
"answered 10 or more others' issues or first-reviewed 2 or more others' PRs in the last year); PRs count "
|
|
96
|
+
"a maintainer's.")
|
|
97
|
+
out.append((f"First answers by the project, last {a['days']} days", lines or ["No outside PRs or issues in this window."]))
|
|
98
|
+
d = r.get("dependency")
|
|
99
|
+
if d:
|
|
100
|
+
lines = [f"{d['merged']} merged pull requests by {d['people']} people in the last {d['days']} days; the most active "
|
|
101
|
+
f"person made {_pct(d['top_share'])} of them. Bus factor {d['bus_factor']}: the fewest people behind half of "
|
|
102
|
+
"the merged work."]
|
|
103
|
+
bus, top = (d.get("peers") or {}).get("bus_factor"), (d.get("peers") or {}).get("top_share")
|
|
104
|
+
if bus:
|
|
105
|
+
lines.append(f"{bus['group'][0].upper()}{bus['group'][1:]} ({bus['repos']}): bus factor typically {bus['typical']} "
|
|
106
|
+
f"(middle half {bus['low']} to {bus['high']})"
|
|
107
|
+
+ (f"; the most active person's share typically {_pct(top['typical'])}." if top else "."))
|
|
108
|
+
else:
|
|
109
|
+
lines = ["Fewer than 20 merged pull requests by people in the last 180 days: not enough to say who carries the work."]
|
|
110
|
+
o = r.get("orgs")
|
|
111
|
+
if o:
|
|
112
|
+
top = ", ".join(f"{x['org']} {_pct(x['share'])}" for x in o["orgs"][:3])
|
|
113
|
+
lines.append((f"Elephant factor {o['factor']}: the fewest organisations behind half the work with a known one"
|
|
114
|
+
if o["factor"] else "No elephant factor: most of the work is by people with no known organisation")
|
|
115
|
+
+ f"; {_pct(o['unknown_share'])} has none known" + (f"; {top}." if top else "."))
|
|
116
|
+
said = (r.get("context") or {}).get("response_policy")
|
|
117
|
+
if said:
|
|
118
|
+
lines.append(f"The contributing guide promises an answer {said['said']}.")
|
|
119
|
+
stated = (r.get("context") or {}).get("stated") or {}
|
|
120
|
+
if stated.get("capacity"):
|
|
121
|
+
lines.append(f"In the project's own words, who maintains it: {stated['capacity']}")
|
|
122
|
+
if stated.get("norms"):
|
|
123
|
+
lines.append(f"In the project's own words, how it works: {stated['norms']}")
|
|
124
|
+
out.append(("Who carries the work", lines))
|
|
125
|
+
lever = r.get("lever")
|
|
126
|
+
if lever:
|
|
127
|
+
rows = "; ".join(f"{b['bucket']} {_pct(b['returned'], 1)}" for b in lever["by_answer"])
|
|
128
|
+
lines = [f"Across {lever['newcomers']:,} newcomers in {lever['repos']} public repos, the share who came back with new work "
|
|
129
|
+
f"within 90 days, by how long their first PR waited for an answer: {rows}."]
|
|
130
|
+
if lever["by_outcome"]:
|
|
131
|
+
lines.append("By outcome: " + "; ".join(f"{b['outcome']} {_pct(b['returned'], 1)}" for b in lever["by_outcome"])
|
|
132
|
+
+ ". Newcomers left without an answer came back least often; this is an association across repos, "
|
|
133
|
+
"not proof that answering brings a given newcomer back.")
|
|
134
|
+
out.append(("Newcomers who came back, by first answer (public repos)", lines))
|
|
135
|
+
st = r.get("stars")
|
|
136
|
+
if st and st.get("gained_12w") is not None:
|
|
137
|
+
# Percentages of a few stars mislead; the benchmarks start at 100 stars too.
|
|
138
|
+
big = (st.get("stars") or 0) >= 100
|
|
139
|
+
line = f"+{st['gained_12w']:,} stars in the last 12 complete weeks" + (f" ({_pct(st['growth_12w'], 1)} growth)" if big else "")
|
|
140
|
+
if st.get("peers"):
|
|
141
|
+
line += f", {st['peers']['place']} of {st['peers']['group']} ({st['peers']['repos']} repos)"
|
|
142
|
+
out.append(("Stars", [line + "."]))
|
|
143
|
+
t = r.get("traffic")
|
|
144
|
+
if t:
|
|
145
|
+
lines = [f"Last {t['days']} days: {t['views_total']:,} views ({t['visitors_total']:,} unique visitors, summed per day) "
|
|
146
|
+
f"and {t['clones_total']:,} clones; kept since {t['since']}."]
|
|
147
|
+
if t["referrers"]:
|
|
148
|
+
lines.append("Top referrers (" + str(t["referrers_day"]) + "): "
|
|
149
|
+
+ ", ".join(f"{x[0]} {x[1]}" for x in t["referrers"][:5]) + ".")
|
|
150
|
+
out.append(("Traffic", lines))
|
|
151
|
+
else:
|
|
152
|
+
out.append(("Traffic", [TRAFFIC_HELP]))
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _waiting_lines(r: dict, repo: str = "") -> list[str]:
|
|
157
|
+
def link(w):
|
|
158
|
+
return f" https://github.com/{repo}/{'pull' if w['kind'] == 'pr' else 'issues'}/{int(w['n'])}" if repo else ""
|
|
159
|
+
rows = [f"{'PR' if w['kind'] == 'pr' else 'Issue'} #{w['n']}: waiting {_days(w['days'])}{link(w)}" for w in r["waiting"][:20]]
|
|
160
|
+
if r["waiting_total"] > len(rows):
|
|
161
|
+
rows.append(f"... and {r['waiting_total'] - len(rows)} more")
|
|
162
|
+
return rows or ["Nobody's first contribution is waiting for an answer."]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def render_text(r: dict, repo: str) -> str:
|
|
166
|
+
out = [f"{repo}: community, updated {_when(r['generated'])}", ""]
|
|
167
|
+
sections = _sections(r)
|
|
168
|
+
for title, lines in sections[:1] + [("Newcomers waiting for a first answer", _waiting_lines(r, repo))] + sections[1:]:
|
|
169
|
+
out += [title + ":", *[f" {line}" for line in lines], ""]
|
|
170
|
+
return "\n".join(out).rstrip()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def render_markdown(r: dict, repo: str) -> str:
|
|
174
|
+
out = [f"### StackVitals Community: {_md(repo)}", ""]
|
|
175
|
+
sections = _sections(r)
|
|
176
|
+
for title, lines in sections[:1] + [("Newcomers waiting for a first answer", _waiting_lines(r))] + sections[1:]:
|
|
177
|
+
out += [f"**{_md(title)}**", *[f"- {_md(line)}" for line in lines], ""]
|
|
178
|
+
return "\n".join(out).rstrip()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _some(*series) -> bool:
|
|
182
|
+
# A chart of only gaps and zeros says nothing; the text sections already say so.
|
|
183
|
+
return any(v for values in series for v in values)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _trends(r: dict) -> str:
|
|
187
|
+
figs, qs = [], r.get("quarters") or []
|
|
188
|
+
labels = [q["quarter"] for q in qs]
|
|
189
|
+
hours = [None if q["median_hours"] is None else round(q["median_hours"], 1) for q in qs]
|
|
190
|
+
if _some(hours):
|
|
191
|
+
figs.append(shell.chart("First answers per quarter", "Quarter", labels,
|
|
192
|
+
[("Median hours to the project's first answer", hours)], y="hours", fmt=lambda v: f"{v:g} h",
|
|
193
|
+
kind="bar"))
|
|
194
|
+
new, items = [q["newcomers"] for q in qs], [q["items"] for q in qs]
|
|
195
|
+
if _some(new, items):
|
|
196
|
+
figs.append(shell.chart("New contributors per quarter", "Quarter", labels,
|
|
197
|
+
[("First-time contributors", new), ("Outside PRs and issues", items)], y="count", kind="bar"))
|
|
198
|
+
weeks = (r.get("stars") or {}).get("weeks") or []
|
|
199
|
+
if len(weeks) >= 2 and _some([w[1] for w in weeks]):
|
|
200
|
+
figs.append(shell.chart("Stars per week", "Week", [w[0] for w in weeks], [("Stars", [w[1] for w in weeks])], y="stars"))
|
|
201
|
+
if not figs:
|
|
202
|
+
return ""
|
|
203
|
+
return ("<h2>Trends</h2><p class=mut>Calendar quarters, this one so far. A quarter before the history can tell newcomers "
|
|
204
|
+
"apart shows no newcomer count.</p>" + shell.charts(figs))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def render_html(r: dict, repo: str, pages=()) -> str:
|
|
208
|
+
e = html.escape
|
|
209
|
+
kind = {"pr": "pull", "issue": "issues"}
|
|
210
|
+
body = []
|
|
211
|
+
sections = _sections(r)
|
|
212
|
+
for title, lines in sections[:1]:
|
|
213
|
+
body.append(f"<h2>{e(title)}</h2><ul>" + "".join(f"<li>{e(x)}</li>" for x in lines) + "</ul>")
|
|
214
|
+
body.append("<h2>Newcomers waiting for a first answer</h2>")
|
|
215
|
+
if r["waiting"]:
|
|
216
|
+
body.append("<div class=tbl><table class=find><tr><th>Item</th><th>Waiting</th></tr>" + "".join(
|
|
217
|
+
f"<tr><td><a href='https://github.com/{e(repo)}/{kind.get(w['kind'], 'issues')}/{int(w['n'])}'>"
|
|
218
|
+
f"{'PR' if w['kind'] == 'pr' else 'Issue'} #{int(w['n'])}</a></td><td>{_days(int(w['days']))}</td></tr>"
|
|
219
|
+
for w in r["waiting"]) + "</table></div>")
|
|
220
|
+
if r["waiting_total"] > len(r["waiting"]):
|
|
221
|
+
body.append(f"<p class=mut>... and {r['waiting_total'] - len(r['waiting'])} more.</p>")
|
|
222
|
+
else:
|
|
223
|
+
body.append("<p>Nobody's first contribution is waiting for an answer.</p>")
|
|
224
|
+
body.append(_trends(r))
|
|
225
|
+
for title, lines in sections[1:]:
|
|
226
|
+
body.append(f"<h2>{e(title)}</h2><ul>" + "".join(f"<li>{e(x)}</li>" for x in lines) + "</ul>")
|
|
227
|
+
body.append("<footer class=mut>Outside contributors are people who are not owners, members, collaborators or bots. "
|
|
228
|
+
"Nobody is ranked and nobody is listed as having left. History is kept in this repo's "
|
|
229
|
+
"<code>stackvitals-data</code> branch. Made by StackVitals.</footer>")
|
|
230
|
+
return shell.page("Community", repo, "community.html", f"Updated {e(_when(r['generated']))}", "".join(body), pages)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def badges(r: dict) -> dict[str, dict]:
|
|
234
|
+
"""Two shields.io endpoint badges: measured facts, and the score (users pick one or both)."""
|
|
235
|
+
f, s = r.get("facts") or {}, r.get("score")
|
|
236
|
+
parts = []
|
|
237
|
+
if f.get("first_answer_hours") is not None:
|
|
238
|
+
parts.append(f"first answer {_h(f['first_answer_hours'])}")
|
|
239
|
+
if f.get("newcomer_return_rate") is not None:
|
|
240
|
+
parts.append(f"newcomers back {_pct(f['newcomer_return_rate'])}")
|
|
241
|
+
elif f.get("newcomer_answered_share") is not None:
|
|
242
|
+
parts.append(f"newcomers answered {_pct(f['newcomer_answered_share'])}")
|
|
243
|
+
facts = {"schemaVersion": 1, "label": "community", "message": " · ".join(parts) or "not enough data yet",
|
|
244
|
+
"color": "blue" if parts else "lightgrey"}
|
|
245
|
+
if s:
|
|
246
|
+
color = "brightgreen" if s["score"] >= 67 else "yellow" if s["score"] >= 40 else "orange"
|
|
247
|
+
scored = {"schemaVersion": 1, "label": "community score", "message": f"{s['score']}/100", "color": color}
|
|
248
|
+
else:
|
|
249
|
+
scored = {"schemaVersion": 1, "label": "community score", "message": "not enough data yet", "color": "lightgrey"}
|
|
250
|
+
return {"community-badge.json": facts, "community-score.json": scored}
|