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,507 @@
|
|
|
1
|
+
"""Where contributors come from and drop out: first answers, the newcomer funnel, newcomers still waiting, star growth and
|
|
2
|
+
traffic, with the same definitions as the crawl benchmarks so the peer comparison is fair."""
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from mergescout.bench.ci import tier
|
|
7
|
+
from mergescout.bench.stars import star_stats
|
|
8
|
+
from stackvitals.ci.bench import RANKS, _about, _label, _rank
|
|
9
|
+
from stackvitals.core import changes, stats
|
|
10
|
+
from stackvitals.core.actors import INSIDER_ASSOC, People, kind_of
|
|
11
|
+
from stackvitals.core.time import iso as _iso
|
|
12
|
+
from stackvitals.core.time import parse as _dt
|
|
13
|
+
|
|
14
|
+
INSIDERS = INSIDER_ASSOC
|
|
15
|
+
DAY = timedelta(days=1)
|
|
16
|
+
HISTORY_DAYS = 60 # a repo's first 60 days of history can't tell a newcomer from someone who came before
|
|
17
|
+
RETURN_DAYS = 90
|
|
18
|
+
MIN_PRS = 10
|
|
19
|
+
MIN_NEWCOMERS = 10
|
|
20
|
+
BURST = 20
|
|
21
|
+
DEPENDENCY_DAYS, MIN_MERGED, MIN_TENURE = 180, 20, 10 # who carries the work; tenure groups need 10
|
|
22
|
+
# Regular responders, checked against labelled crawl comments (bench/accuracy/community).
|
|
23
|
+
RESPONDER_ISSUES, RESPONDER_REVIEWS, RESPONDER_DAYS = 10, 2, 360
|
|
24
|
+
# Only these associations can be a first contribution; CONTRIBUTOR means earlier commits, even before our history.
|
|
25
|
+
FIRST_TIMERS = {"NONE", "FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", None}
|
|
26
|
+
# The facts behind the score: lower is better for waits, higher for the shares.
|
|
27
|
+
SCORE_FACTS = [("first_answer_hours", "First answer to outside PRs", "hours", -1),
|
|
28
|
+
("newcomer_answered_share", "Newcomers answered within 2 weeks", "share", 1),
|
|
29
|
+
("newcomer_return_rate", "Newcomers who come back within 90 days", "share", 1)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hours(a: str, b: str) -> float:
|
|
33
|
+
# Signed: callers compare orderings themselves.
|
|
34
|
+
return (_dt(b) - _dt(a)).total_seconds() / 3600
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _median(values: list[float]) -> float | None:
|
|
38
|
+
m = stats.median(values)
|
|
39
|
+
return None if m is None else round(m, 2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _human(login: str | None, typ: str | None) -> bool:
|
|
43
|
+
return kind_of(login, typ) == "person"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def insiders(prs: list[dict], issues: list[dict]) -> set[str]:
|
|
47
|
+
"""Maintainers by the shared rule: a listed role anywhere, or anyone who merged a PR (private org members show as
|
|
48
|
+
CONTRIBUTOR; pipx's main merger did); bots and agents never."""
|
|
49
|
+
return People(prs, issues).insiders()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def regular_responders(prs: list[dict], issues: list[dict], ins: set | frozenset, now: datetime) -> set[str]:
|
|
53
|
+
"""People without a listed role who answer others: comments on 10+ other people's issues, or the first review on 2+
|
|
54
|
+
other people's PRs, in the last 360 days (triagers and private org members show as CONTRIBUTOR)."""
|
|
55
|
+
since = _iso(now - timedelta(days=RESPONDER_DAYS))
|
|
56
|
+
on, first = Counter(), Counter()
|
|
57
|
+
for r in issues:
|
|
58
|
+
on.update({c[1] for c in r.get("cm") or [] if len(c) > 3 and c[0] and c[0] >= since and c[1] != r.get("au")
|
|
59
|
+
and _human(c[1], c[3])})
|
|
60
|
+
for r in prs:
|
|
61
|
+
got = min(((e[1], e[2]) for e in r.get("ev") or [] if e[0] == "review" and len(e) > 5 and e[1] and e[2]
|
|
62
|
+
and e[2] != r.get("au") and e[3] != "PENDING" and _human(e[2], e[5])), default=None)
|
|
63
|
+
if got and got[0] >= since:
|
|
64
|
+
first[got[1]] += 1
|
|
65
|
+
found = {p for p, n in on.items() if n >= RESPONDER_ISSUES} | {p for p, n in first.items() if n >= RESPONDER_REVIEWS}
|
|
66
|
+
return (found | triagers(issues, now)) - set(ins)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def triagers(issues: list[dict], now: datetime) -> set[str]:
|
|
70
|
+
"""People who labelled, assigned, marked a duplicate or closed someone else's issue in the last 360 days: GitHub lets
|
|
71
|
+
only people with triage rights do that, so they act for the project whatever role their comments show. On labelled
|
|
72
|
+
public issues this found 8 project answers among 11 comments the regular-responder rule missed (bench/accuracy/community)."""
|
|
73
|
+
since = _iso(now - timedelta(days=RESPONDER_DAYS))
|
|
74
|
+
out = set()
|
|
75
|
+
for r in issues:
|
|
76
|
+
# Each act counts by its own date: [login, when].
|
|
77
|
+
out |= {t[0] for t in r.get("tr") or [] if isinstance(t, (list, tuple)) and len(t) == 2 and (t[1] or "") >= since}
|
|
78
|
+
closer = _closer(r)
|
|
79
|
+
if closer and closer != r.get("au") and not closer.endswith("[bot]") and (r.get("cl") or "") >= since:
|
|
80
|
+
out.add(closer)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _for(rec: dict, ins: set, resp: set | None) -> set:
|
|
85
|
+
# Regular responders answer issues and discussions for the project; PRs still need a maintainer, as the crawl
|
|
86
|
+
# benchmarks count them.
|
|
87
|
+
return resp if resp is not None and rec.get("t") in ("issue", "discussion") else ins
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def bursts(prs: list[dict], issues: list[dict], ins: set | frozenset = frozenset()) -> set[str]:
|
|
91
|
+
# An outside account opening BURST items on one day is automated (bat: 73 PRs on one day); maintainers do this too.
|
|
92
|
+
days = Counter((r["au"], r["cr"][:10]) for r in prs + issues if r.get("au") and r.get("cr") and r["au"] not in ins
|
|
93
|
+
and r.get("as") not in INSIDERS)
|
|
94
|
+
return {au for (au, _), n in days.items() if n >= BURST}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def outside(rec: dict, ins: set | frozenset = frozenset(), skip: set | frozenset = frozenset()) -> bool:
|
|
98
|
+
au = rec.get("au")
|
|
99
|
+
return rec.get("as") not in INSIDERS and _human(au, rec.get("at")) and au not in ins and au not in skip
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _closer(rec: dict) -> str | None:
|
|
103
|
+
if rec.get("clb"):
|
|
104
|
+
return rec["clb"]
|
|
105
|
+
closes = sorted((e for e in rec.get("ev") or [] if e[0] == "closed" and len(e) > 2), key=lambda e: e[1])
|
|
106
|
+
return closes[-1][2] if closes else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def answered_at(rec: dict, ins: set | None = None, comments: bool = True) -> str | None:
|
|
110
|
+
"""The first answer: with the maintainers given, a maintainer's review or reply, a merge, or a close by someone other
|
|
111
|
+
than the author. Without them it is the crawl benchmarks' answer: any other person's review, a merge or a close."""
|
|
112
|
+
au, strict = rec.get("au"), ins is not None
|
|
113
|
+
times = [rec.get("mg")]
|
|
114
|
+
# A close counts unless we know the author closed it (records saved before closers were kept count it); a
|
|
115
|
+
# discussion's closer isn't read, so its close never counts.
|
|
116
|
+
if rec.get("cl") and rec.get("t") != "discussion" and not (strict and _closer(rec) == au):
|
|
117
|
+
times.append(rec["cl"])
|
|
118
|
+
for e in rec.get("ev") or []:
|
|
119
|
+
if e[0] == "review" and len(e) > 5 and e[5] == "User" and e[2] and e[2] != au and not e[2].endswith("[bot]") \
|
|
120
|
+
and e[3] != "PENDING" and (not strict or e[2] in ins or e[4] in INSIDERS):
|
|
121
|
+
times.append(e[1])
|
|
122
|
+
if comments:
|
|
123
|
+
for c in rec.get("cm") or []:
|
|
124
|
+
if c[0] and c[1] != au and _human(c[1], c[3]) and (not strict or c[1] in ins or c[2] in INSIDERS):
|
|
125
|
+
times.append(c[0])
|
|
126
|
+
times = [x for x in times if x and x >= (rec.get("cr") or "")]
|
|
127
|
+
return min(times) if times else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def covered_since(prs: list[dict], issues: list[dict]) -> str | None:
|
|
131
|
+
"""How far back the history is complete: items are read when updated in the last 360 days, so an old item updated
|
|
132
|
+
lately doesn't mean everything since it was seen."""
|
|
133
|
+
recs = [r for r in prs + issues if r.get("cr")]
|
|
134
|
+
if not recs:
|
|
135
|
+
return None
|
|
136
|
+
oldest = min(r["cr"] for r in recs)
|
|
137
|
+
first_fetch = min((r["f"] for r in recs if r.get("f")), default=None)
|
|
138
|
+
return max(oldest, _iso(_dt(first_fetch) - timedelta(days=360))) if first_fetch else oldest
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _contributions(prs: list[dict], issues: list[dict], ins=frozenset(), skip=frozenset()) -> dict[str, list[dict]]:
|
|
142
|
+
by = {}
|
|
143
|
+
for r in sorted(prs + issues, key=lambda r: (r.get("cr") or "", r.get("n") or 0)):
|
|
144
|
+
if r.get("cr") and outside(r, ins, skip):
|
|
145
|
+
by.setdefault(r["au"], []).append(r)
|
|
146
|
+
return by
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _came_back(items: list[dict]) -> bool:
|
|
150
|
+
# New work at least a day later: an issue and the PR fixing it minutes apart are one contribution.
|
|
151
|
+
first = items[0]["cr"]
|
|
152
|
+
return any(24 <= _hours(first, r["cr"]) <= RETURN_DAYS * 24 for r in items[1:])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def funnel(prs: list[dict], issues: list[dict], now: datetime, ins: set | None = None) -> dict:
|
|
156
|
+
"""First-time contributors of the last year, how many came back within 90 days, regulars and maintainers."""
|
|
157
|
+
ins = insiders(prs, issues) if ins is None else ins
|
|
158
|
+
skip = bursts(prs, issues, ins)
|
|
159
|
+
start = covered_since(prs, issues) or _iso(now)
|
|
160
|
+
new_from = max(_iso(_dt(start) + timedelta(days=HISTORY_DAYS)), _iso(now - timedelta(days=365)))
|
|
161
|
+
judged_until = _iso(now - timedelta(days=RETURN_DAYS))
|
|
162
|
+
newcomers = judged = returned = 0
|
|
163
|
+
quarters = {}
|
|
164
|
+
for login, items in _contributions(prs, issues, ins, skip).items():
|
|
165
|
+
for r in items:
|
|
166
|
+
age = (now - _dt(r["cr"])).days
|
|
167
|
+
if age < 364:
|
|
168
|
+
quarters.setdefault(login, set()).add(age // 91)
|
|
169
|
+
if items[0]["cr"] < new_from:
|
|
170
|
+
continue
|
|
171
|
+
newcomers += 1
|
|
172
|
+
if items[0]["cr"] <= judged_until:
|
|
173
|
+
judged += 1
|
|
174
|
+
returned += _came_back(items)
|
|
175
|
+
year = _iso(now - timedelta(days=365))
|
|
176
|
+
active = set()
|
|
177
|
+
for r in prs + issues:
|
|
178
|
+
if r.get("au") in ins and (r.get("cr") or "") >= year:
|
|
179
|
+
active.add(r["au"])
|
|
180
|
+
active |= {e[2] for e in r.get("ev") or [] if e[0] in ("review", "merged") and len(e) > 2 and e[2] in ins and e[1] >= year}
|
|
181
|
+
active |= {c[1] for c in r.get("cm") or [] if c[1] in ins and c[0] >= year}
|
|
182
|
+
return {"newcomers": newcomers, "judged": judged, "returned": returned,
|
|
183
|
+
"return_rate": round(returned / judged, 4) if judged else None,
|
|
184
|
+
"regulars": sum(1 for q in quarters.values() if len(q) >= 3), "maintainers": len(active)}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _answers(items: list[dict], ins: set, earlier: int, now: datetime | None = None, resp: set | None = None) -> dict:
|
|
188
|
+
answers = [answered_at(r, _for(r, ins, resp)) for r in items]
|
|
189
|
+
waits = [_hours(r["cr"], a) for r, a in zip(items, answers) if a]
|
|
190
|
+
# Items still waiting count as "not answered after this long" (Kaplan-Meier), so slow repos can't look fast.
|
|
191
|
+
km = None
|
|
192
|
+
if now:
|
|
193
|
+
# An item closed without an answer stops the clock at its close; only open ones are still waiting.
|
|
194
|
+
km = stats.km_median([_hours(r["cr"], a or r.get("cl") or r.get("mg") or _iso(now)) for r, a in zip(items, answers)],
|
|
195
|
+
[bool(a) for a in answers])
|
|
196
|
+
km["closed_unanswered"] = sum(1 for r, a in zip(items, answers) if not a and (r.get("cl") or r.get("mg")))
|
|
197
|
+
km["waiting"] = sum(1 for r, a in zip(items, answers) if not a and not (r.get("cl") or r.get("mg")))
|
|
198
|
+
return {"items": len(items), "answered": round(len(waits) / len(items), 4) if items else None,
|
|
199
|
+
"median_hours": _median(waits), "km": km,
|
|
200
|
+
"within_1d": round(sum(w <= 24 for w in waits) / len(items), 4) if items else None, "earlier": earlier}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def first_answer(prs: list[dict], issues: list[dict], now: datetime, days: int = 90, ins: set | None = None,
|
|
204
|
+
resp: set | None = None, discussions: list[dict] | None = None) -> dict:
|
|
205
|
+
"""Outside contributors' PRs and issues of the last `days` (at least a day old): how often and how fast the project
|
|
206
|
+
answered (a maintainer, or for issues also a regular responder); automated accounts are left out and counted, and
|
|
207
|
+
the 9 months before say whether outside work stopped."""
|
|
208
|
+
ins = insiders(prs, issues) if ins is None else ins
|
|
209
|
+
resp = set(ins) | regular_responders(prs, issues, ins, now) if resp is None else resp
|
|
210
|
+
skip = bursts(prs, issues, ins)
|
|
211
|
+
lo, hi, year = _iso(now - timedelta(days=days)), _iso(now - DAY), _iso(now - timedelta(days=365))
|
|
212
|
+
|
|
213
|
+
def pick(rs):
|
|
214
|
+
return [r for r in rs if outside(r, ins, skip) and lo <= (r.get("cr") or "") < hi and not r.get("draft")]
|
|
215
|
+
|
|
216
|
+
def before(rs):
|
|
217
|
+
return sum(1 for r in rs if outside(r, ins, skip) and year <= (r.get("cr") or "") < lo)
|
|
218
|
+
left = [r for r in prs + issues if r.get("au") in skip and lo <= (r.get("cr") or "") < hi]
|
|
219
|
+
return {"days": days, "prs": _answers(pick(prs), ins, before(prs), now),
|
|
220
|
+
"issues": {**_answers(pick(issues), ins, before(issues), now, resp), "responders": len(set(resp) - set(ins))},
|
|
221
|
+
"left_out": {"accounts": len({r["au"] for r in left}), "items": len(left)} if left else None,
|
|
222
|
+
"discussions": _discussions(pick(discussions or []), ins, before(discussions or []), now, resp)}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _discussions(items: list[dict], ins: set, earlier: int, now: datetime, resp: set) -> dict | None:
|
|
226
|
+
"""Outside discussions answered by the project (the issue rule), and how many questions got an accepted answer from
|
|
227
|
+
anyone (a peer's answer still helped the asker)."""
|
|
228
|
+
if not items:
|
|
229
|
+
return None
|
|
230
|
+
asked = [r for r in items if r.get("qa")]
|
|
231
|
+
return {**_answers(items, ins, earlier, now, resp),
|
|
232
|
+
"answer_chosen": round(sum(1 for r in asked if r.get("ans")) / len(asked), 4) if asked else None}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def weekly_changes(prs: list[dict], issues: list[dict], now: datetime, ins: set | None = None,
|
|
236
|
+
resp: set | None = None) -> list[dict]:
|
|
237
|
+
"""This week's first answers to outside contributors against the four weeks before, judged against how much this
|
|
238
|
+
repo's weeks usually swing."""
|
|
239
|
+
ins = insiders(prs, issues) if ins is None else ins
|
|
240
|
+
resp = set(ins) | regular_responders(prs, issues, ins, now) if resp is None else resp
|
|
241
|
+
skip = bursts(prs, issues, ins)
|
|
242
|
+
items = [(r, answered_at(r, _for(r, ins, resp))) for r in prs + issues
|
|
243
|
+
if r.get("cr") and not r.get("draft") and outside(r, ins, skip)]
|
|
244
|
+
|
|
245
|
+
def week(t: datetime) -> list[dict]:
|
|
246
|
+
(ws, we), (bs, be) = changes.windows(t)
|
|
247
|
+
# Answers after the week being judged hadn't happened yet then.
|
|
248
|
+
waits = lambda a, b: [_hours(r["cr"], x) for r, x in items if a <= r["cr"] < b and x and x < we] # noqa: E731
|
|
249
|
+
return [changes.times("community.first_answer", "first answer to outside contributors", waits(ws, we), waits(bs, be),
|
|
250
|
+
better=-1, unit="h")]
|
|
251
|
+
return changes.annotate(changes.calibrate(week, now))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def quarterly(prs: list[dict], issues: list[dict], now: datetime, count: int = 4, ins: set | None = None,
|
|
255
|
+
resp: set | None = None) -> list[dict]:
|
|
256
|
+
"""The last four calendar quarters (this one so far): outside PRs and issues, how fast the project first answered,
|
|
257
|
+
and how many people contributed here for the first time."""
|
|
258
|
+
ins = insiders(prs, issues) if ins is None else ins
|
|
259
|
+
resp = set(ins) | regular_responders(prs, issues, ins, now) if resp is None else resp
|
|
260
|
+
skip = bursts(prs, issues, ins)
|
|
261
|
+
year, q, quarters = now.year, (now.month - 1) // 3, []
|
|
262
|
+
for _ in range(count):
|
|
263
|
+
quarters.append((f"{year} Q{q + 1}", f"{year}-{3 * q + 1:02d}-01T00:00:00Z"))
|
|
264
|
+
year, q = (year, q - 1) if q else (year - 1, 3)
|
|
265
|
+
quarters.reverse()
|
|
266
|
+
label = lambda ts: f"{ts[:4]} Q{(int(ts[5:7]) - 1) // 3 + 1}" # noqa: E731
|
|
267
|
+
hi, items = _iso(now - DAY), {k: [] for k, _ in quarters}
|
|
268
|
+
for r in prs + issues:
|
|
269
|
+
cr = r.get("cr") or ""
|
|
270
|
+
if cr and cr < hi and not r.get("draft") and outside(r, ins, skip) and label(cr) in items:
|
|
271
|
+
items[label(cr)].append(r)
|
|
272
|
+
new_from = _iso(_dt(covered_since(prs, issues) or _iso(now)) + timedelta(days=HISTORY_DAYS))
|
|
273
|
+
firsts = Counter(label(xs[0]["cr"]) for xs in _contributions(prs, issues, ins, skip).values() if xs[0]["cr"] >= new_from)
|
|
274
|
+
out = []
|
|
275
|
+
for k, start in quarters:
|
|
276
|
+
a = _answers(items[k], ins, 0, resp=resp)
|
|
277
|
+
# Before the history can tell a newcomer from an earlier contributor, the quarter has no newcomer count.
|
|
278
|
+
out.append({"quarter": k, "items": a["items"], "median_hours": a["median_hours"], "within_1d": a["within_1d"],
|
|
279
|
+
"newcomers": firsts[k] if start >= new_from else None})
|
|
280
|
+
return out
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def newcomers_waiting(prs: list[dict], issues: list[dict], now: datetime, ins: set | None = None,
|
|
284
|
+
resp: set | None = None) -> list[dict]:
|
|
285
|
+
"""Open PRs and issues that are someone's first contribution here and that nobody answered for the project, oldest
|
|
286
|
+
first. GitHub's CONTRIBUTOR association marks people who contributed before our history began."""
|
|
287
|
+
ins, out = (insiders(prs, issues) if ins is None else ins), []
|
|
288
|
+
resp = set(ins) | regular_responders(prs, issues, ins, now) if resp is None else resp
|
|
289
|
+
# Only items inside the complete history can be called someone's first, and only threads we see whole are unanswered.
|
|
290
|
+
since = covered_since(prs, issues) or ""
|
|
291
|
+
for login, items in _contributions(prs, issues, ins, bursts(prs, issues, ins)).items():
|
|
292
|
+
first = items[0]
|
|
293
|
+
seen_whole = len(first.get("cm") or []) < 10 and (first.get("cmn") or 0) <= len(first.get("cm") or [])
|
|
294
|
+
if first.get("st") == "OPEN" and not first.get("draft") and first.get("as") in FIRST_TIMERS and first["cr"] >= since \
|
|
295
|
+
and seen_whole and answered_at(first, _for(first, ins, resp)) is None:
|
|
296
|
+
out.append({"kind": first.get("t") or "pr", "n": first["n"], "created": first["cr"],
|
|
297
|
+
"days": (now - _dt(first["cr"])).days})
|
|
298
|
+
return sorted(out, key=lambda w: w["created"])
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def bench_facts(prs: list[dict], now: datetime) -> dict:
|
|
302
|
+
"""This repo's community facts with exactly the crawl benchmarks' definitions (PRs only; reviews, merges, closes)."""
|
|
303
|
+
ins = insiders(prs, [])
|
|
304
|
+
skip = bursts(prs, [], ins)
|
|
305
|
+
lo, hi = _iso(now - timedelta(days=90)), _iso(now - DAY)
|
|
306
|
+
recent = [r for r in prs if outside(r, ins, skip) and lo <= (r.get("cr") or "") < hi]
|
|
307
|
+
waits = [(_hours(r["cr"], a) if (a := answered_at(r, comments=False)) else None) for r in recent]
|
|
308
|
+
enough = len(recent) >= MIN_PRS
|
|
309
|
+
new_from = _iso(_dt(covered_since(prs, []) or _iso(now)) + timedelta(days=HISTORY_DAYS))
|
|
310
|
+
seen_until, judged_until = _iso(now - timedelta(days=14)), _iso(now - timedelta(days=RETURN_DAYS + 30))
|
|
311
|
+
firsts = [items for items in _contributions(prs, [], ins, skip).values() if items[0]["cr"] >= new_from]
|
|
312
|
+
seen = [items[0] for items in firsts if items[0]["cr"] <= seen_until]
|
|
313
|
+
judged = [items for items in firsts if items[0]["cr"] <= judged_until]
|
|
314
|
+
answered = [f for f in seen if (a := answered_at(f, comments=False)) and _hours(f["cr"], a) <= 14 * 24]
|
|
315
|
+
back = [items for items in judged if _came_back(items)]
|
|
316
|
+
return {"first_answer_hours": _median([w for w in waits if w is not None]) if enough else None,
|
|
317
|
+
"answered_1d_share": round(sum(1 for w in waits if w is not None and w <= 24) / len(recent), 4) if enough else None,
|
|
318
|
+
"newcomer_answered_share": round(len(answered) / len(seen), 4) if len(seen) >= MIN_NEWCOMERS else None,
|
|
319
|
+
"newcomer_return_rate": round(len(back) / len(judged), 4) if len(judged) >= MIN_NEWCOMERS else None}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def dependency(prs: list[dict], now: datetime, days: int = DEPENDENCY_DAYS) -> dict | None:
|
|
323
|
+
"""Who carries the merged work of the last 180 days, without names: how many people, the top person's share and the
|
|
324
|
+
bus factor (CHAOSS: the fewest people behind half of it). Bots and AI agents don't count; None under 20 merged PRs."""
|
|
325
|
+
since = _iso(now - timedelta(days=days))
|
|
326
|
+
counts = Counter(r["au"] for r in prs if r.get("mg") and r["mg"] >= since and _human(r.get("au"), r.get("at")))
|
|
327
|
+
total = sum(counts.values())
|
|
328
|
+
if total < MIN_MERGED:
|
|
329
|
+
return None
|
|
330
|
+
ranked, running, bus = sorted(counts.values(), reverse=True), 0, 0
|
|
331
|
+
for n in ranked:
|
|
332
|
+
running, bus = running + n, bus + 1
|
|
333
|
+
if running * 2 >= total:
|
|
334
|
+
break
|
|
335
|
+
return {"days": days, "merged": total, "people": len(counts), "top_share": round(ranked[0] / total, 4), "bus_factor": bus}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def elephant(prs: list[dict], affiliation: dict[str, str | None], now: datetime, days: int = DEPENDENCY_DAYS) -> dict | None:
|
|
339
|
+
"""The organisations behind the merged work (CHAOSS elephant factor: the fewest behind half of it) and their shares;
|
|
340
|
+
work by people with no known organisation is shown as unknown, and no factor is claimed when it is most of it."""
|
|
341
|
+
from stackvitals.community.affiliation import key
|
|
342
|
+
since = _iso(now - timedelta(days=days))
|
|
343
|
+
people = Counter(r["au"] for r in prs if r.get("mg") and r["mg"] >= since and _human(r.get("au"), r.get("at")))
|
|
344
|
+
total = sum(people.values())
|
|
345
|
+
if total < MIN_MERGED:
|
|
346
|
+
return None
|
|
347
|
+
orgs, members = Counter(), Counter()
|
|
348
|
+
for login, n in people.items():
|
|
349
|
+
org = affiliation.get(key(login)) or None
|
|
350
|
+
orgs[org] += n
|
|
351
|
+
members[org] += 1
|
|
352
|
+
unknown = orgs.pop(None, 0)
|
|
353
|
+
known = total - unknown
|
|
354
|
+
factor = None
|
|
355
|
+
if known * 2 > total:
|
|
356
|
+
running = 0
|
|
357
|
+
for factor, (_org, n) in enumerate(orgs.most_common(), 1):
|
|
358
|
+
running += n
|
|
359
|
+
if running * 2 >= known:
|
|
360
|
+
break
|
|
361
|
+
return {"days": days, "merged": total, "people": len(people), "factor": factor,
|
|
362
|
+
"unknown_share": round(unknown / total, 4),
|
|
363
|
+
# Organisations of one person are named together, never alone: a single name would point at that person.
|
|
364
|
+
"orgs": [{"org": o, "prs": n, "share": round(n / total, 4)} for o, n in orgs.most_common() if members[o] > 1][:5]
|
|
365
|
+
+ ([{"org": "organisations of one person", "prs": solo, "share": round(solo / total, 4)}]
|
|
366
|
+
if (solo := sum(n for o, n in orgs.items() if members[o] == 1)) else [])}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def cohorts(prs: list[dict], issues: list[dict], now: datetime, ins: set | None = None, months: int = 12) -> dict:
|
|
370
|
+
"""Newcomers by the month they first contributed, and how many came back within 90 days and between 90 and 180 days
|
|
371
|
+
(a month too recent to judge says None)."""
|
|
372
|
+
ins = insiders(prs, issues) if ins is None else ins
|
|
373
|
+
first = (now.year * 12 + now.month - 1) - (months - 1)
|
|
374
|
+
keep = {f"{(first + i) // 12:04d}-{(first + i) % 12 + 1:02d}" for i in range(months)}
|
|
375
|
+
# As in the funnel: the first 60 days of known history can't tell a newcomer from someone who came before.
|
|
376
|
+
new_from = _iso(_dt(covered_since(prs, issues) or _iso(now)) + timedelta(days=HISTORY_DAYS))
|
|
377
|
+
out = {}
|
|
378
|
+
for _login, items in _contributions(prs, issues, ins, bursts(prs, issues, ins)).items():
|
|
379
|
+
month = items[0]["cr"][:7]
|
|
380
|
+
if month not in keep or items[0]["cr"] < new_from:
|
|
381
|
+
continue
|
|
382
|
+
c = out.setdefault(month, {"month": month, "newcomers": 0, "back_by_90d": 0, "back_90_180d": 0})
|
|
383
|
+
c["newcomers"] += 1
|
|
384
|
+
later = [_hours(items[0]["cr"], r["cr"]) / 24 for r in items[1:]]
|
|
385
|
+
c["back_by_90d"] += any(1 <= d <= 90 for d in later)
|
|
386
|
+
c["back_90_180d"] += any(90 < d <= 180 for d in later)
|
|
387
|
+
for c in out.values():
|
|
388
|
+
start = _dt(c["month"] + "-01T00:00:00Z")
|
|
389
|
+
# A cohort is judged once its last day is 90 (or 180) days old.
|
|
390
|
+
end = start + timedelta(days=31)
|
|
391
|
+
if now < end + timedelta(days=90):
|
|
392
|
+
c["back_by_90d"] = None
|
|
393
|
+
if now < end + timedelta(days=180):
|
|
394
|
+
c["back_90_180d"] = None
|
|
395
|
+
return {"months": [out[m] for m in sorted(out)]}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def answers_by_tenure(prs: list[dict], issues: list[dict], now: datetime, days: int = 90, ins: set | None = None,
|
|
399
|
+
resp: set | None = None) -> dict:
|
|
400
|
+
"""First answers to outside contributors' PRs and issues of the last `days`, for newcomers (their first contribution
|
|
401
|
+
here) and regulars (they contributed before): how many, the median wait and the share answered within a day."""
|
|
402
|
+
ins = insiders(prs, issues) if ins is None else ins
|
|
403
|
+
resp = set(ins) | regular_responders(prs, issues, ins, now) if resp is None else resp
|
|
404
|
+
skip = bursts(prs, issues, ins)
|
|
405
|
+
lo, hi = _iso(now - timedelta(days=days)), _iso(now - DAY)
|
|
406
|
+
# A repo's first 60 days of history can't tell a newcomer from someone who came before.
|
|
407
|
+
new_from = _iso(_dt(covered_since(prs, issues) or _iso(now)) + timedelta(days=HISTORY_DAYS))
|
|
408
|
+
firsts = {id(items[0]) for items in _contributions(prs, issues, ins, skip).values()}
|
|
409
|
+
waits = {"newcomers": [], "regulars": []}
|
|
410
|
+
for r in prs + issues:
|
|
411
|
+
if not outside(r, ins, skip) or not lo <= (r.get("cr") or "") < hi or r.get("draft"):
|
|
412
|
+
continue
|
|
413
|
+
first = id(r) in firsts
|
|
414
|
+
if first and r["cr"] < new_from:
|
|
415
|
+
continue
|
|
416
|
+
a = answered_at(r, _for(r, ins, resp))
|
|
417
|
+
waits["newcomers" if first else "regulars"].append(_hours(r["cr"], a) if a else None)
|
|
418
|
+
out = {"days": days}
|
|
419
|
+
for group, ws in waits.items():
|
|
420
|
+
got = [w for w in ws if w is not None]
|
|
421
|
+
enough = len(ws) >= MIN_TENURE
|
|
422
|
+
out[group] = {"n": len(ws), "median_h": _median(got) if enough and got else None,
|
|
423
|
+
"answered_1d_share": round(sum(1 for w in got if w <= 24) / len(ws), 4) if enough else None}
|
|
424
|
+
return out
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def peer_range(bench: dict | None, language: str | None, stars: int | None, metric: str) -> dict | None:
|
|
428
|
+
"""Similar public repos' typical value and middle half for a metric, shown instead of a rank: counts like the bus
|
|
429
|
+
factor tie too often for a rank to mean much."""
|
|
430
|
+
found = _group(bench, language, stars, metric)
|
|
431
|
+
if not found:
|
|
432
|
+
return None
|
|
433
|
+
key, g = found
|
|
434
|
+
q = g[metric]
|
|
435
|
+
return {"group": _label(key), "repos": q.get("repos") or g.get("repos"), "typical": q["p50"], "low": q["p25"], "high": q["p75"]}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _ok(q) -> bool:
|
|
439
|
+
return isinstance(q, dict) and all(isinstance(q.get(f"p{int(r * 100)}"), (int, float)) for r in RANKS)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _group(bench: dict | None, language: str | None, stars: int | None, metric: str) -> tuple[str, dict] | None:
|
|
443
|
+
groups = (bench or {}).get("groups") if isinstance(bench, dict) else None
|
|
444
|
+
if not isinstance(groups, dict):
|
|
445
|
+
return None
|
|
446
|
+
size = tier(stars)
|
|
447
|
+
for key in (f"{language}|{size}", f"{language}|all", f"all|{size}", "all|all"):
|
|
448
|
+
g = groups.get(key)
|
|
449
|
+
if isinstance(g, dict) and _ok(g.get(metric)):
|
|
450
|
+
return key, g
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def score(facts: dict, bench: dict | None, language: str | None, stars: int | None) -> dict | None:
|
|
455
|
+
"""0-100: on average, the share of similar repos this one does better than, over the facts it has; each fact is shown,
|
|
456
|
+
a missing one is left out (never scored as zero), and fewer than two facts give no score."""
|
|
457
|
+
found = _group(bench, language, stars, "first_answer_hours") or _group(bench, language, stars, "newcomer_answered_share")
|
|
458
|
+
if not found:
|
|
459
|
+
return None
|
|
460
|
+
key, g = found
|
|
461
|
+
shown, left_out = [], []
|
|
462
|
+
for name, label, kind, better in SCORE_FACTS:
|
|
463
|
+
if facts.get(name) is None or not _ok(g.get(name)):
|
|
464
|
+
left_out.append(name)
|
|
465
|
+
continue
|
|
466
|
+
r = _rank(facts[name], g[name])
|
|
467
|
+
beats = 1 - r if better < 0 else r
|
|
468
|
+
# Whole percents, and the score is exactly their average: anyone can recompute it from the page.
|
|
469
|
+
shown.append({"key": name, "name": label, "kind": kind, "you": facts[name], "typical": g[name]["p50"],
|
|
470
|
+
"better_than": int(beats * 100 + 0.5)})
|
|
471
|
+
if len(shown) < 2:
|
|
472
|
+
return None
|
|
473
|
+
return {"score": int(sum(f["better_than"] for f in shown) / len(shown) + 0.5), "group": _label(key), "repos": g["repos"],
|
|
474
|
+
"built_at": (bench or {}).get("built_at"), "facts": shown, "left_out": left_out}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def star_trend(weeks: dict, stars: int | None, now: datetime, bench: dict | None = None,
|
|
478
|
+
language: str | None = None) -> dict | None:
|
|
479
|
+
"""Weekly stars (GitHub's history, no names), the gain over the last 12 complete weeks and its place among peers."""
|
|
480
|
+
rows = [{"week": int(k), "total": int(v)} for k, v in (weeks or {}).items() if str(k).isdigit()]
|
|
481
|
+
if not rows:
|
|
482
|
+
return None
|
|
483
|
+
series = [[datetime.fromtimestamp(r["week"], now.tzinfo).strftime("%Y-%m-%d"), r["total"]]
|
|
484
|
+
for r in sorted(rows, key=lambda r: r["week"])][-52:]
|
|
485
|
+
out = {"weeks": series, "stars": stars, **(star_stats(rows, stars, now) or {"gained_12w": None, "growth_12w": None})}
|
|
486
|
+
found = _group(bench, language, stars, "growth_12w") if out["growth_12w"] is not None and (stars or 0) >= 100 else None
|
|
487
|
+
if found:
|
|
488
|
+
key, g = found
|
|
489
|
+
r = _rank(out["growth_12w"], g["growth_12w"])
|
|
490
|
+
place = f"above about {_about(r)}%" if r >= 0.5 else f"below about {_about(1 - r)}%"
|
|
491
|
+
out["peers"] = {"group": _label(key), "repos": g["repos"], "place": place,
|
|
492
|
+
"typical_gain": (g.get("gained_12w") or {}).get("p50")}
|
|
493
|
+
return out
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def traffic_summary(traffic: dict, now: datetime, days: int = 90) -> dict | None:
|
|
497
|
+
"""Views and clones per day (kept past GitHub's 14 days) and the latest top referrers."""
|
|
498
|
+
if not traffic or not traffic.get("views"):
|
|
499
|
+
return None
|
|
500
|
+
lo = (now - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
501
|
+
pick = lambda key: sorted([d, *v] for d, v in (traffic.get(key) or {}).items() if d >= lo) # noqa: E731
|
|
502
|
+
views, clones = pick("views"), pick("clones")
|
|
503
|
+
latest = max(traffic.get("referrers") or {"": []}, default="")
|
|
504
|
+
return {"days": days, "since": min(traffic["views"]), "views": views, "clones": clones,
|
|
505
|
+
"views_total": sum(v[1] for v in views), "visitors_total": sum(v[2] for v in views),
|
|
506
|
+
"clones_total": sum(v[1] for v in clones),
|
|
507
|
+
"referrers": (traffic.get("referrers") or {}).get(latest, []), "referrers_day": latest or None}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from stackvitals.community.analyze import (answers_by_tenure, bench_facts, cohorts, dependency, elephant, first_answer,
|
|
4
|
+
funnel, insiders,
|
|
5
|
+
newcomers_waiting, peer_range, quarterly, regular_responders, score, star_trend,
|
|
6
|
+
traffic_summary, weekly_changes)
|
|
7
|
+
from stackvitals.connectors.discourse import answers as forum_answers
|
|
8
|
+
from stackvitals.core.time import iso
|
|
9
|
+
|
|
10
|
+
WAITING_ROWS = 50
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _lever(bench: dict | None) -> dict | None:
|
|
14
|
+
# The crawl's newcomer table: what happened to first-timers across public repos, by how long they waited.
|
|
15
|
+
table = (bench or {}).get("newcomers") if isinstance(bench, dict) else None
|
|
16
|
+
if not isinstance(table, dict) or not isinstance(table.get("by_answer"), list) or not table.get("newcomers"):
|
|
17
|
+
return None
|
|
18
|
+
rows = [b for b in table["by_answer"] if isinstance(b, dict) and isinstance(b.get("returned"), (int, float))
|
|
19
|
+
and isinstance(b.get("newcomers"), int) and isinstance(b.get("bucket"), str)]
|
|
20
|
+
outcome = [b for b in table.get("by_outcome") or [] if isinstance(b, dict) and isinstance(b.get("returned"), (int, float))]
|
|
21
|
+
return {"newcomers": table["newcomers"], "repos": table.get("repos"), "by_answer": rows, "by_outcome": outcome} if rows else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build(prs: dict, issues: dict, stars: dict, traffic: dict, repo: dict, now: datetime, bench: dict | None = None,
|
|
25
|
+
star_bench: dict | None = None, context: dict | None = None, affiliation: dict | None = None,
|
|
26
|
+
discussions: dict | None = None, forum: list[dict] | None = None, chat: list[dict] | None = None) -> dict:
|
|
27
|
+
"""Everything Community shows, from Team Flow's PR history, the issues, the star history and (if kept) traffic."""
|
|
28
|
+
all_prs = (prs.get("open") or []) + (prs.get("closed") or [])
|
|
29
|
+
all_issues = (issues.get("open") or []) + (issues.get("closed") or [])
|
|
30
|
+
language, star_count = repo.get("language"), repo.get("stars")
|
|
31
|
+
# Who the maintainers are is decided once for the whole page.
|
|
32
|
+
ins = insiders(all_prs, all_issues)
|
|
33
|
+
# Issues count an answer from a maintainer or a regular responder; PRs from a maintainer.
|
|
34
|
+
resp = set(ins) | regular_responders(all_prs, all_issues, ins, now)
|
|
35
|
+
facts = bench_facts(all_prs, now)
|
|
36
|
+
waiting = newcomers_waiting(all_prs, all_issues, now, ins, resp)
|
|
37
|
+
carried = dependency(all_prs, now)
|
|
38
|
+
if carried:
|
|
39
|
+
# Similar public repos' typical values beside this repo's, not folded into the score (it keeps one meaning).
|
|
40
|
+
carried["peers"] = {m: peer_range(bench, language, star_count, m) for m in ("bus_factor", "top_share")}
|
|
41
|
+
return {"generated": iso(now), "facts": facts, "score": score(facts, bench, language, star_count),
|
|
42
|
+
"has_bench": bool(bench),
|
|
43
|
+
"funnel": funnel(all_prs, all_issues, now, ins),
|
|
44
|
+
"answers": first_answer(all_prs, all_issues, now, ins=ins, resp=resp,
|
|
45
|
+
discussions=(discussions or {}).get("open", []) + (discussions or {}).get("closed", [])),
|
|
46
|
+
"waiting": waiting[:WAITING_ROWS], "waiting_total": len(waiting), "lever": _lever(bench),
|
|
47
|
+
"stars": star_trend((stars or {}).get("weeks") or {}, star_count, now, star_bench, language),
|
|
48
|
+
"traffic": traffic_summary(traffic or {}, now),
|
|
49
|
+
"quarters": quarterly(all_prs, all_issues, now, ins=ins, resp=resp),
|
|
50
|
+
"dependency": carried, "tenure": answers_by_tenure(all_prs, all_issues, now, ins=ins, resp=resp),
|
|
51
|
+
"changes": weekly_changes(all_prs, all_issues, now, ins, resp),
|
|
52
|
+
# What the repo states about itself (read weekly), the organisations behind the work, and newcomer cohorts.
|
|
53
|
+
"context": context, "orgs": elephant(all_prs, affiliation, now) if affiliation is not None else None,
|
|
54
|
+
"cohorts": cohorts(all_prs, all_issues, now, ins),
|
|
55
|
+
# A Discourse forum, when connected: questions answered there instead of in issues.
|
|
56
|
+
"forum": forum_answers(forum, now) if forum else None, "chat": forum_answers(chat, now) if chat else None}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""StackVitals' numbers beside the CHAOSS metrics they answer (chaoss.community, 89 metrics read 2026-10-23): which
|
|
2
|
+
function computes each, and where ours differs and why. docs/chaoss.md is built from this list, and a test checks every
|
|
3
|
+
function named here exists, so the page can't point at code that is gone."""
|
|
4
|
+
|
|
5
|
+
KB = "https://chaoss.community/kb/metric-"
|
|
6
|
+
|
|
7
|
+
# CHAOSS metric, its page slug, our function, how ours differs (empty when the definitions agree)
|
|
8
|
+
MAPPING = [
|
|
9
|
+
("Time to First Response", "time-to-first-response", "stackvitals.community.analyze.first_answer",
|
|
10
|
+
"Only answers from the project count (maintainers, people who triage, regular responders, or a close), not the "
|
|
11
|
+
"first comment from anyone; issues still waiting stay in the median (Kaplan-Meier) instead of dropping out. On "
|
|
12
|
+
"labelled public issues, 46% of the first comments other tools count are drive-bys (bench/accuracy/community)."),
|
|
13
|
+
("Issue Response Time", "issue-response-time", "stackvitals.community.analyze.first_answer",
|
|
14
|
+
"The same rule as the first response above, for issues."),
|
|
15
|
+
("Newcomer Experience", "newcomer-experience", "stackvitals.community.analyze.answers_by_tenure",
|
|
16
|
+
"Measured, not surveyed: how fast first-time contributors are answered against people who contributed before."),
|
|
17
|
+
("New Contributors", "new-contributors", "stackvitals.community.analyze.funnel",
|
|
18
|
+
"Bots, AI agents and accounts opening 20 or more items in a day are left out; the funnel also says how many came "
|
|
19
|
+
"back within 90 days (CHAOSS has no single retention metric; Inactive Contributors is the nearest)."),
|
|
20
|
+
("Contributor Absence Factor", "contributor-absence-factor", "stackvitals.community.analyze.dependency",
|
|
21
|
+
"The same definition (the fewest people behind half of the merged work, formerly Bus Factor) over 180 days, with "
|
|
22
|
+
"bots and AI agents left out and similar public repos' middle half shown instead of a rank."),
|
|
23
|
+
("Elephant Factor", "elephant-factor", "stackvitals.community.analyze.elephant",
|
|
24
|
+
"Organisations from the company on people's public profiles; people without one are counted as unknown and shown, "
|
|
25
|
+
"never guessed. The factor is over the work with a known organisation, and none is claimed when most is unknown."),
|
|
26
|
+
("Organizational Diversity", "organizational-diversity", "stackvitals.community.analyze.elephant",
|
|
27
|
+
"The share of merged work by organisation, unknown shown."),
|
|
28
|
+
("Project Popularity", "project-popularity", "stackvitals.community.analyze.star_trend",
|
|
29
|
+
"Weekly stars kept over time and placed among similar repos, so a launch spike reads as one."),
|
|
30
|
+
("Clones", "clones", "stackvitals.community.analyze.traffic_summary",
|
|
31
|
+
"Kept past GitHub's 14 days when a traffic token is set."),
|
|
32
|
+
("Change Request Acceptance Ratio", "change-request-acceptance-ratio", "stackvitals.flow.analyze.analyze",
|
|
33
|
+
"Outsiders' pull requests only (insiders merge their own), with its sample size and range."),
|
|
34
|
+
("Change Request Review Duration", "change-request-review-duration", "stackvitals.flow.analyze.stages",
|
|
35
|
+
"Split into waiting for a first review and the review itself, counted from when the PR was ready, not opened."),
|
|
36
|
+
("Change Requests Duration", "change-requests-duration", "stackvitals.core.delivery.phases",
|
|
37
|
+
"LinearB's four phases (coding, pickup, review, deploy), with review split into its time to approve and time to merge, "
|
|
38
|
+
"from first commit to deployment."),
|
|
39
|
+
("Release Frequency", "release-frequency", "stackvitals.core.delivery.dora",
|
|
40
|
+
"Counted as DORA's deployment frequency from the source the repo really ships with (deployments, deploy "
|
|
41
|
+
"workflows or releases), with the source shown."),
|
|
42
|
+
("Time to Close", "time-to-close", "stackvitals.connectors.tracker.lead_times",
|
|
43
|
+
"From Jira or Linear when connected: created to done, by issue type."),
|
|
44
|
+
("Labor Investment", "labor-investment", "stackvitals.core.investment.mix",
|
|
45
|
+
"Where merged work went each month (feature, fix, maintenance, security, docs), with how each PR was sorted."),
|
|
46
|
+
("Test Coverage", "test-coverage", "stackvitals.ci.analyze._coverage",
|
|
47
|
+
"From the coverage reports CI already uploads, with the change on each pull request."),
|
|
48
|
+
("Upstream Code Dependencies", "upstream-code-dependencies", "stackvitals.deps.supply.sbom",
|
|
49
|
+
"From GitHub's dependency graph, with upgrade evidence from public repos for each package."),
|
|
50
|
+
("SPDX Document", "spdx-document", "stackvitals.deps.supply.parse_spdx",
|
|
51
|
+
"Read from the SPDX SBOM GitHub generates; StackVitals doesn't write one."),
|
|
52
|
+
("Code of Conduct for a Project", "code-of-conduct-for-a-project", "stackvitals.portal.inventory.repo_files",
|
|
53
|
+
"Present or missing, per repo in the Portal catalog."),
|
|
54
|
+
("Bot Activity", "bot-activity", "stackvitals.core.actors.kind_of",
|
|
55
|
+
"Bots and AI agents are found in every module and counted apart from people, never mixed into people's numbers."),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def markdown() -> str:
|
|
60
|
+
lines = ["# StackVitals and CHAOSS", "",
|
|
61
|
+
"[CHAOSS](https://chaoss.community) defines open source community health metrics. Each row below is a "
|
|
62
|
+
"CHAOSS metric StackVitals answers, the code that computes it, and where ours differs and why. The list is "
|
|
63
|
+
"built from `stackvitals/community/chaoss.py`, and a test checks every function it names exists.", "",
|
|
64
|
+
"| CHAOSS metric | Computed by | How StackVitals differs |", "|---|---|---|"]
|
|
65
|
+
for name, slug, where, differs in MAPPING:
|
|
66
|
+
lines.append(f"| [{name}]({KB}{slug}/) | `{where.removeprefix('stackvitals.')}` | {differs or 'Same definition.'} |")
|
|
67
|
+
lines += ["", f"{len(MAPPING)} of CHAOSS's 89 metrics. Most of the rest are about events, governance documents and "
|
|
68
|
+
"surveys, which no repo's history can measure; the community page shows a repo's stated policies "
|
|
69
|
+
"(governance, funding, response times) beside its numbers instead.", ""]
|
|
70
|
+
return "\n".join(lines)
|