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
mergescout/labels.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
# Label names that mark an issue as open to newcomers; GitHub matches any of them, ignoring case.
|
|
4
|
+
OPPORTUNITY_LABELS = ("good first issue", "good-first-issue", "help wanted", "help-wanted", "beginner",
|
|
5
|
+
"beginner friendly", "easy", "first-timers-only", "starter", "good first contribution")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def norm_label(name: str) -> str:
|
|
9
|
+
# "Good-First-Issue :wave:" and "good_first_issue" both become "good first issue".
|
|
10
|
+
text = re.sub(r":[a-z0-9_+-]+:", " ", (name or "").lower())
|
|
11
|
+
return " ".join(re.sub(r"[^a-z0-9]+", " ", text).split())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
HOT = {norm_label(label) for label in OPPORTUNITY_LABELS}
|
|
15
|
+
# "help wanted" invites anyone; these labels promise a task sized for a first contribution.
|
|
16
|
+
BEGINNER = HOT - {"help wanted"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def has_opportunity(labels: list[str]) -> bool:
|
|
20
|
+
return any(norm_label(label) in HOT for label in labels or [])
|
mergescout/mcp_server.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from mcp.server.mcpserver import MCPServer
|
|
10
|
+
|
|
11
|
+
from mergescout.github.releases import ReleaseError, Releases
|
|
12
|
+
from mergescout.tool.action_pack import build_action_pack
|
|
13
|
+
from mergescout.tool.live import GitHubLive, LiveError
|
|
14
|
+
from mergescout.tool.queries import Snapshot
|
|
15
|
+
from mergescout.tool.refs import parse_ref
|
|
16
|
+
from mergescout.tool.store import DEFAULT_REPO, SnapshotStore, data_token, home
|
|
17
|
+
from mergescout.tool.tracking import Tracker
|
|
18
|
+
|
|
19
|
+
TOOL_NAMES = ("find_issues", "action_pack", "repo_health", "repos_needing_help", "check_issue", "similar_issues",
|
|
20
|
+
"explain", "track", "report_outcome")
|
|
21
|
+
INSTRUCTIONS = ("MergeScout answers from a daily snapshot of GitHub repos: which open issues an outsider can "
|
|
22
|
+
"realistically get merged, with evidence (merge rates with sample sizes, review times, walls). "
|
|
23
|
+
"Call check_issue before telling someone an issue is free. Quote the evidence and say "
|
|
24
|
+
"'not enough evidence' whenever a result does.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NoSnapshot(RuntimeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class App:
|
|
32
|
+
def __init__(self, root: Path | None = None, releases=None, live=None, log=None):
|
|
33
|
+
self.root = root or home()
|
|
34
|
+
self.log = log or (lambda msg: print(msg, file=sys.stderr))
|
|
35
|
+
repo = os.environ.get("MERGESCOUT_DATA_REPO", DEFAULT_REPO)
|
|
36
|
+
self.store = SnapshotStore(self.root, releases or Releases(data_token(), repo))
|
|
37
|
+
self.live = live or GitHubLive(data_token())
|
|
38
|
+
self.tracker = Tracker(self.root)
|
|
39
|
+
self._snap = None
|
|
40
|
+
# Tools run in worker threads; only one of them may check for and download a snapshot.
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
|
|
43
|
+
def snapshot(self) -> Snapshot:
|
|
44
|
+
with self._lock:
|
|
45
|
+
if self._snap is None:
|
|
46
|
+
path = self.store.ensure(log=self.log)
|
|
47
|
+
if path is None:
|
|
48
|
+
raise NoSnapshot("No issue snapshot is available yet: it isn't published for outside use. "
|
|
49
|
+
"Maintainers with access to the private data set MERGESCOUT_TOKEN, then run "
|
|
50
|
+
"`mergescout update`.")
|
|
51
|
+
self._snap = Snapshot(path)
|
|
52
|
+
return self._snap
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def plain(exc: Exception) -> str:
|
|
56
|
+
if isinstance(exc, httpx.HTTPError):
|
|
57
|
+
return f"Could not reach GitHub (network problem: {exc}). Answers from the snapshot still work."
|
|
58
|
+
if isinstance(exc, ReleaseError):
|
|
59
|
+
return (f"Could not read the issue snapshot ({exc}). It isn't published for outside use yet; maintainers "
|
|
60
|
+
"with access to the private data set MERGESCOUT_TOKEN.")
|
|
61
|
+
return str(exc)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _safe(fn):
|
|
65
|
+
# Agents read plain answers better than stack traces.
|
|
66
|
+
def run(*args, **kwargs):
|
|
67
|
+
try:
|
|
68
|
+
return fn(*args, **kwargs)
|
|
69
|
+
except (NoSnapshot, LiveError, ValueError, httpx.HTTPError, ReleaseError) as exc:
|
|
70
|
+
return {"error": plain(exc)}
|
|
71
|
+
run.__name__, run.__doc__ = fn.__name__, fn.__doc__
|
|
72
|
+
return run
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def live_status(app: App, ref) -> dict:
|
|
76
|
+
status = app.live.issue_status(ref.owner, ref.name, ref.number)
|
|
77
|
+
if status.get("linked_open_prs") is not None:
|
|
78
|
+
return status
|
|
79
|
+
# Without a token GitHub hides linked PRs; keep the open PR the snapshot last saw.
|
|
80
|
+
try:
|
|
81
|
+
snap = app.snapshot()
|
|
82
|
+
except NoSnapshot:
|
|
83
|
+
return status
|
|
84
|
+
row = snap.issue(ref.repo, ref.number)
|
|
85
|
+
if row and row["linked_open_prs"]:
|
|
86
|
+
status = {**status, "linked_open_prs": row["linked_open_prs"], "available": False,
|
|
87
|
+
"reasons": status.get("reasons", []) + [f"open PR (seen in the snapshot of {snap.built_at})"]}
|
|
88
|
+
return status
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def verify_live(app: App, out: dict, limit: int) -> dict:
|
|
92
|
+
# Real queries (2026-10-16): 7 of 60 snapshot results were taken within a day; check the list in one call.
|
|
93
|
+
refs = [parse_ref(r["issue"]) for r in out["results"]]
|
|
94
|
+
try:
|
|
95
|
+
statuses = app.live.issue_statuses([(f.owner, f.name, f.number) for f in refs])
|
|
96
|
+
except Exception as exc: # noqa: BLE001 - any odd answer falls back to the snapshot list
|
|
97
|
+
out["results"] = out["results"][:limit]
|
|
98
|
+
out["note"] += f" Live check failed ({plain(exc)}); availability is from the snapshot."
|
|
99
|
+
return out
|
|
100
|
+
kept, taken, gone = [], 0, 0
|
|
101
|
+
for r, f in zip(out["results"], refs):
|
|
102
|
+
s = statuses.get((f.owner, f.name, f.number))
|
|
103
|
+
if s is None or not s["available"]:
|
|
104
|
+
taken += s is not None
|
|
105
|
+
gone += s is None
|
|
106
|
+
continue
|
|
107
|
+
r = {**r, "checked_live": True}
|
|
108
|
+
if s["older_claims"]:
|
|
109
|
+
r["earlier_claims"] = (f"{len(s['older_claims'])} older offer(s) to work on it, the last on "
|
|
110
|
+
f"{s['older_claims'][-1]['at'][:10]}; ask on the issue before starting")
|
|
111
|
+
if s.get("note"):
|
|
112
|
+
r["live_note"] = s["note"]
|
|
113
|
+
kept.append(r)
|
|
114
|
+
out["note"] = (f"Checked live on GitHub just now: {taken} of {len(refs)} candidates are taken or not ready and "
|
|
115
|
+
f"{gone} no longer exist; they are left out.")
|
|
116
|
+
out["results"] = kept[:limit]
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_server(app: App) -> MCPServer:
|
|
121
|
+
server = MCPServer("mergescout", instructions=INSTRUCTIONS)
|
|
122
|
+
|
|
123
|
+
@server.tool()
|
|
124
|
+
def find_issues(language: str | None = None, topic: str | None = None, beginner_labels_only: bool = False,
|
|
125
|
+
min_score: float = 0.0, allow_walls: bool = True, fresh: bool = False, limit: int = 10,
|
|
126
|
+
detail: bool = False) -> dict:
|
|
127
|
+
"""Open issues an outsider can likely get merged, best first, each with its evidence.
|
|
128
|
+
topic: plain words matched against titles, labels and bodies. allow_walls=False hides repos with a CLA,
|
|
129
|
+
DCO, issue-first rule or AI ban. fresh=True also lists beginner issues opened after the snapshot."""
|
|
130
|
+
return _safe(_find)(language, topic, beginner_labels_only, min_score, allow_walls, fresh, limit, detail)
|
|
131
|
+
|
|
132
|
+
def _find(language, topic, beginner_labels_only, min_score, allow_walls, fresh, limit, detail):
|
|
133
|
+
snap = app.snapshot()
|
|
134
|
+
limit = max(1, min(limit, 50))
|
|
135
|
+
# With a token, twice the candidates are read so the list stays full after taken ones are dropped.
|
|
136
|
+
live = bool(getattr(app.live, "token", None))
|
|
137
|
+
out = snap.find_issues(language, topic, beginner_labels_only, min_score, allow_walls,
|
|
138
|
+
min(limit * 2, 50) if live else limit, detail)
|
|
139
|
+
if live and out["results"]:
|
|
140
|
+
out = verify_live(app, out, limit)
|
|
141
|
+
if fresh:
|
|
142
|
+
since = datetime.fromisoformat(snap.built_at.replace("Z", "+00:00"))
|
|
143
|
+
try:
|
|
144
|
+
out["fresh"] = snap.with_repo_stats(app.live.fresh_issues(language, since, limit))[:limit]
|
|
145
|
+
except (LiveError, httpx.HTTPError) as exc:
|
|
146
|
+
out["fresh"], out["fresh_note"] = [], f"fresh lane unavailable: {plain(exc)}"
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
@server.tool()
|
|
150
|
+
def action_pack(issue: str) -> dict:
|
|
151
|
+
"""Everything needed to start on one issue: live status, what it asks, accepted outsider PRs to copy,
|
|
152
|
+
the project's rules as a checklist, a size hint and next steps. issue: owner/name#number or a URL."""
|
|
153
|
+
def run():
|
|
154
|
+
ref = parse_ref(issue)
|
|
155
|
+
try:
|
|
156
|
+
status, failed = live_status(app, ref), None
|
|
157
|
+
except (LiveError, httpx.HTTPError) as exc:
|
|
158
|
+
status, failed = None, plain(exc)
|
|
159
|
+
pack = build_action_pack(app.snapshot(), ref, status)
|
|
160
|
+
if failed:
|
|
161
|
+
pack["status"]["note"] = f"live check failed ({failed}); status is from the snapshot of {app.snapshot().built_at}"
|
|
162
|
+
return pack
|
|
163
|
+
return _safe(run)()
|
|
164
|
+
|
|
165
|
+
@server.tool()
|
|
166
|
+
def repo_health(repo: str, detail: bool = False) -> dict:
|
|
167
|
+
"""How outsiders fare in one repo: merge rates with sample sizes, review time, walls, how to reach it."""
|
|
168
|
+
def run():
|
|
169
|
+
out = app.snapshot().repo_health(parse_ref(repo).repo, detail)
|
|
170
|
+
return out or {"error": f"{repo} is not in the snapshot (not crawled yet or too inactive)."}
|
|
171
|
+
return _safe(run)()
|
|
172
|
+
|
|
173
|
+
@server.tool()
|
|
174
|
+
def repos_needing_help(language: str | None = None, limit: int = 10) -> dict:
|
|
175
|
+
"""Repos that merge outsider PRs but have very few active maintainers."""
|
|
176
|
+
return _safe(lambda: app.snapshot().repos_needing_help(language, limit))()
|
|
177
|
+
|
|
178
|
+
@server.tool()
|
|
179
|
+
def check_issue(issue: str) -> dict:
|
|
180
|
+
"""Live check on GitHub right now: open, assigned, linked PRs, recent claims, maintainer 'please wait'."""
|
|
181
|
+
def run():
|
|
182
|
+
return live_status(app, parse_ref(issue))
|
|
183
|
+
return _safe(run)()
|
|
184
|
+
|
|
185
|
+
@server.tool()
|
|
186
|
+
def similar_issues(issue: str, limit: int = 5) -> dict:
|
|
187
|
+
"""Free issues whose titles share words with this one (keyword match)."""
|
|
188
|
+
def run():
|
|
189
|
+
ref = parse_ref(issue)
|
|
190
|
+
return app.snapshot().similar_issues(ref.repo, ref.number, limit)
|
|
191
|
+
return _safe(run)()
|
|
192
|
+
|
|
193
|
+
@server.tool()
|
|
194
|
+
def explain(issue: str) -> dict:
|
|
195
|
+
"""The full evidence record behind an issue's score: counts, ranges, windows, exclusions and notes."""
|
|
196
|
+
def run():
|
|
197
|
+
ref = parse_ref(issue)
|
|
198
|
+
return app.snapshot().explain(ref.repo, ref.number) or {"error": f"{ref.key} is not in the snapshot."}
|
|
199
|
+
return _safe(run)()
|
|
200
|
+
|
|
201
|
+
@server.tool()
|
|
202
|
+
def track(issue: str | None = None, untrack: bool = False) -> dict:
|
|
203
|
+
"""Track an issue (stored on this machine); untrack=True stops tracking it.
|
|
204
|
+
Without an issue: list tracked issues and what changed since the last look."""
|
|
205
|
+
def run():
|
|
206
|
+
if issue and untrack:
|
|
207
|
+
app.tracker.untrack(parse_ref(issue).key)
|
|
208
|
+
elif issue:
|
|
209
|
+
ref = parse_ref(issue)
|
|
210
|
+
app.tracker.track(ref.key, live_status(app, ref))
|
|
211
|
+
return {"tracked": sorted(app.tracker.tracked()), "changes": [] if issue else app.tracker.updates(app.live)}
|
|
212
|
+
return _safe(run)()
|
|
213
|
+
|
|
214
|
+
@server.tool()
|
|
215
|
+
def report_outcome(issue: str, outcome: str, note: str = "") -> dict:
|
|
216
|
+
"""Record what happened (started, pr_opened, merged, closed_unmerged, abandoned, taken_by_someone_else).
|
|
217
|
+
Kept on this machine only."""
|
|
218
|
+
return _safe(lambda: app.tracker.report_outcome(parse_ref(issue).key, outcome, note))()
|
|
219
|
+
|
|
220
|
+
@server.resource("corpus://stats", mime_type="application/json")
|
|
221
|
+
def stats() -> str:
|
|
222
|
+
"""Snapshot date and coverage."""
|
|
223
|
+
try:
|
|
224
|
+
return json.dumps(app.snapshot().stats())
|
|
225
|
+
except NoSnapshot as exc:
|
|
226
|
+
return json.dumps({"error": str(exc)})
|
|
227
|
+
|
|
228
|
+
return server
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import orjson
|
|
6
|
+
|
|
7
|
+
from mergescout.config import load_config
|
|
8
|
+
from mergescout.github.graphql import GitHubGraphQL, GraphQLError
|
|
9
|
+
from mergescout.measure.api_cost import measure_repo
|
|
10
|
+
from mergescout.measure.count_repos import count_repos, listing_requests
|
|
11
|
+
from mergescout.measure.jsonl import append_jsonl, read_jsonl
|
|
12
|
+
from mergescout.measure.report import render_markdown, summarize
|
|
13
|
+
from mergescout.measure.sample import build_sample_queries, collect_sample
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_api_cost(gql, sample: list[dict], results_path: Path, now: datetime, limit: int | None = None) -> int:
|
|
17
|
+
done = {r["repo"] for r in read_jsonl(results_path)}
|
|
18
|
+
measured = 0
|
|
19
|
+
for entry in sample:
|
|
20
|
+
if entry["repo"] in done:
|
|
21
|
+
continue
|
|
22
|
+
if limit is not None and measured >= limit:
|
|
23
|
+
break
|
|
24
|
+
try:
|
|
25
|
+
record = measure_repo(gql, entry["repo"], now)
|
|
26
|
+
except GraphQLError as exc:
|
|
27
|
+
# Not recorded, so the next run tries this repo again.
|
|
28
|
+
print(f"{entry['repo']}: failed ({exc})")
|
|
29
|
+
continue
|
|
30
|
+
record["bucket"] = entry.get("bucket")
|
|
31
|
+
append_jsonl(results_path, record)
|
|
32
|
+
measured += 1
|
|
33
|
+
print(f"{entry['repo']}: {record['status']}")
|
|
34
|
+
return measured
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: list[str] | None = None) -> int:
|
|
38
|
+
parser = argparse.ArgumentParser(prog="python -m mergescout.measure", description="Phase 0 measurements.")
|
|
39
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
40
|
+
sub.add_parser("sample", help="Pick about 300 repos across star ranges")
|
|
41
|
+
api = sub.add_parser("api-cost", help="Measure API points per sampled repo (safe to re-run)")
|
|
42
|
+
api.add_argument("--limit", type=int, default=None)
|
|
43
|
+
sub.add_parser("count-repos", help="Count public repos by star range")
|
|
44
|
+
rep = sub.add_parser("report", help="Print the budget report")
|
|
45
|
+
rep.add_argument("--daily-points", type=int, default=120_000)
|
|
46
|
+
args = parser.parse_args(argv)
|
|
47
|
+
|
|
48
|
+
cfg = load_config()
|
|
49
|
+
out = cfg.data_dir / "measure"
|
|
50
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
now = datetime.now(timezone.utc)
|
|
52
|
+
recent = (now - timedelta(days=30)).date()
|
|
53
|
+
|
|
54
|
+
if args.cmd == "report":
|
|
55
|
+
print(render_markdown(summarize(read_jsonl(out / "api_cost.jsonl"), daily_points=args.daily_points)))
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
gql = GitHubGraphQL(cfg.require_token())
|
|
59
|
+
if args.cmd == "sample":
|
|
60
|
+
sample = collect_sample(gql, build_sample_queries(recent))
|
|
61
|
+
(out / "sample.json").write_bytes(orjson.dumps(sample, option=orjson.OPT_INDENT_2))
|
|
62
|
+
print(f"Sampled {len(sample)} repos -> {out / 'sample.json'}")
|
|
63
|
+
elif args.cmd == "api-cost":
|
|
64
|
+
sample = orjson.loads((out / "sample.json").read_bytes())
|
|
65
|
+
run_api_cost(gql, sample, out / "api_cost.jsonl", now, args.limit)
|
|
66
|
+
elif args.cmd == "count-repos":
|
|
67
|
+
counts = count_repos(gql, recent)
|
|
68
|
+
counts["listing_requests"] = listing_requests(counts["rows"])
|
|
69
|
+
(out / "count_repos.json").write_bytes(orjson.dumps(counts, option=orjson.OPT_INDENT_2))
|
|
70
|
+
print(orjson.dumps(counts, option=orjson.OPT_INDENT_2).decode())
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
from mergescout.github.graphql import NotFound
|
|
4
|
+
from mergescout.timeutil import parse_time
|
|
5
|
+
|
|
6
|
+
PR_HISTORY_QUERY = """
|
|
7
|
+
query($owner: String!, $name: String!, $cursor: String) {
|
|
8
|
+
rateLimit { cost remaining resetAt }
|
|
9
|
+
repository(owner: $owner, name: $name) {
|
|
10
|
+
pullRequests(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) {
|
|
11
|
+
pageInfo { hasNextPage endCursor }
|
|
12
|
+
nodes {
|
|
13
|
+
number createdAt mergedAt closedAt state authorAssociation author { login }
|
|
14
|
+
reviews(first: 1) { nodes { submittedAt authorAssociation } }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
INCREMENTAL_QUERY = """
|
|
22
|
+
query($owner: String!, $name: String!, $cursor: String) {
|
|
23
|
+
rateLimit { cost remaining resetAt }
|
|
24
|
+
repository(owner: $owner, name: $name) {
|
|
25
|
+
pullRequests(first: 100, after: $cursor, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
26
|
+
pageInfo { hasNextPage endCursor }
|
|
27
|
+
nodes {
|
|
28
|
+
number updatedAt createdAt mergedAt closedAt state authorAssociation author { login }
|
|
29
|
+
reviews(first: 1) { nodes { submittedAt authorAssociation } }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
OPEN_ISSUES_QUERY = """
|
|
37
|
+
query($owner: String!, $name: String!, $cursor: String) {
|
|
38
|
+
rateLimit { cost remaining resetAt }
|
|
39
|
+
repository(owner: $owner, name: $name) {
|
|
40
|
+
issues(first: 100, after: $cursor, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
41
|
+
pageInfo { hasNextPage endCursor }
|
|
42
|
+
nodes {
|
|
43
|
+
number updatedAt
|
|
44
|
+
assignees(first: 5) { totalCount }
|
|
45
|
+
labels(first: 10) { nodes { name } }
|
|
46
|
+
closedByPullRequestsReferences(first: 5, includeClosedPrs: true) { nodes { number state } }
|
|
47
|
+
comments(last: 5) { nodes { createdAt authorAssociation author { login } body } }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def walk_connection(gql, query: str, owner: str, name: str, connection: str, time_field: str,
|
|
56
|
+
since: datetime, max_pages: int) -> dict:
|
|
57
|
+
cursor = None
|
|
58
|
+
pages = cost = items = 0
|
|
59
|
+
capped = False
|
|
60
|
+
while True:
|
|
61
|
+
result = gql.query(query, {"owner": owner, "name": name, "cursor": cursor})
|
|
62
|
+
pages += 1
|
|
63
|
+
cost += result.cost
|
|
64
|
+
repo = result.data.get("repository")
|
|
65
|
+
if repo is None:
|
|
66
|
+
raise NotFound(f"{owner}/{name} not found")
|
|
67
|
+
conn = repo[connection]
|
|
68
|
+
nodes = conn["nodes"]
|
|
69
|
+
in_window = [n for n in nodes if (parse_time(n.get(time_field)) or since) >= since]
|
|
70
|
+
items += len(in_window)
|
|
71
|
+
if len(in_window) < len(nodes) or not conn["pageInfo"]["hasNextPage"]:
|
|
72
|
+
break
|
|
73
|
+
if pages >= max_pages:
|
|
74
|
+
capped = True
|
|
75
|
+
break
|
|
76
|
+
cursor = conn["pageInfo"]["endCursor"]
|
|
77
|
+
return {"pages": pages, "cost": cost, "items": items, "capped": capped}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def measure_repo(gql, full_name: str, now: datetime) -> dict:
|
|
81
|
+
owner, name = full_name.split("/", 1)
|
|
82
|
+
try:
|
|
83
|
+
full = walk_connection(gql, PR_HISTORY_QUERY, owner, name, "pullRequests", "createdAt",
|
|
84
|
+
now - timedelta(days=365), max_pages=30)
|
|
85
|
+
incremental = walk_connection(gql, INCREMENTAL_QUERY, owner, name, "pullRequests", "updatedAt",
|
|
86
|
+
now - timedelta(days=1), max_pages=30)
|
|
87
|
+
issues = walk_connection(gql, OPEN_ISSUES_QUERY, owner, name, "issues", "updatedAt",
|
|
88
|
+
now - timedelta(days=90), max_pages=20)
|
|
89
|
+
except NotFound:
|
|
90
|
+
return {"repo": full_name, "status": "not_found"}
|
|
91
|
+
return {"repo": full_name, "status": "ok", "full": full, "incremental": incremental, "issues": issues}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from datetime import date
|
|
3
|
+
|
|
4
|
+
COUNT_RANGES = [("50-99", "50..99"), ("100-199", "100..199"), ("200-499", "200..499"),
|
|
5
|
+
("500-999", "500..999"), ("1k-5k", "1000..4999"), ("5k+", ">=5000")]
|
|
6
|
+
|
|
7
|
+
COUNT_QUERY = """
|
|
8
|
+
query($q: String!) {
|
|
9
|
+
rateLimit { cost remaining resetAt }
|
|
10
|
+
search(query: $q, type: REPOSITORY, first: 1) { repositoryCount }
|
|
11
|
+
}
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def count_repos(gql, pushed_since: date) -> dict:
|
|
16
|
+
rows = []
|
|
17
|
+
cost = 0
|
|
18
|
+
for label, stars in COUNT_RANGES:
|
|
19
|
+
base = f"stars:{stars} fork:false archived:false"
|
|
20
|
+
total = gql.query(COUNT_QUERY, {"q": base})
|
|
21
|
+
active = gql.query(COUNT_QUERY, {"q": f"{base} pushed:>={pushed_since.isoformat()}"})
|
|
22
|
+
cost += total.cost + active.cost
|
|
23
|
+
rows.append({"range": label, "total": total.data["search"]["repositoryCount"],
|
|
24
|
+
"active": active.data["search"]["repositoryCount"]})
|
|
25
|
+
return {"rows": rows, "cost": cost}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def listing_requests(rows: list[dict]) -> int:
|
|
29
|
+
# Search pages hold 100 repos, so listing every active repo takes at least this many calls.
|
|
30
|
+
return sum(math.ceil(row["active"] / 100) for row in rows)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import orjson
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def read_jsonl(path: Path) -> list[dict]:
|
|
7
|
+
if not path.exists():
|
|
8
|
+
return []
|
|
9
|
+
return [orjson.loads(line) for line in path.read_bytes().splitlines() if line.strip()]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def append_jsonl(path: Path, record: dict) -> None:
|
|
13
|
+
with path.open("ab") as f:
|
|
14
|
+
f.write(orjson.dumps(record) + b"\n")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
def _mean(values: list[float]) -> float:
|
|
2
|
+
return sum(values) / len(values) if values else 0.0
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _percentile(values: list[float], p: float) -> float:
|
|
6
|
+
if not values:
|
|
7
|
+
return 0.0
|
|
8
|
+
ordered = sorted(values)
|
|
9
|
+
return ordered[round((len(ordered) - 1) * p)]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def summarize(results: list[dict], daily_points: int = 120_000,
|
|
13
|
+
tier_sizes: tuple[int, ...] = (10_000, 25_000, 50_000, 100_000)) -> dict:
|
|
14
|
+
ok = [r for r in results if r.get("status") == "ok"]
|
|
15
|
+
full = [r["full"]["cost"] for r in ok]
|
|
16
|
+
incr = [r["incremental"]["cost"] for r in ok]
|
|
17
|
+
issues = [r["issues"]["cost"] for r in ok]
|
|
18
|
+
# Totals use the mean, not the median, because a few huge repos eat most of the budget.
|
|
19
|
+
fill_per_repo = _mean(full) + _mean(issues)
|
|
20
|
+
refresh_per_repo = _mean(incr) + _mean(issues)
|
|
21
|
+
tiers = [{"tier": n,
|
|
22
|
+
"first_fill_days": n * fill_per_repo / daily_points,
|
|
23
|
+
"refresh_cycle_days": n * refresh_per_repo / daily_points} for n in tier_sizes]
|
|
24
|
+
buckets = {}
|
|
25
|
+
for bucket in sorted({r.get("bucket") or "?" for r in ok}):
|
|
26
|
+
rows = [r for r in ok if (r.get("bucket") or "?") == bucket]
|
|
27
|
+
costs = [r["full"]["cost"] for r in rows]
|
|
28
|
+
buckets[bucket] = {"repos": len(rows), "full_median": _percentile(costs, 0.5),
|
|
29
|
+
"full_p90": _percentile(costs, 0.9),
|
|
30
|
+
"issues_median": _percentile([r["issues"]["cost"] for r in rows], 0.5)}
|
|
31
|
+
return {
|
|
32
|
+
"daily_points": daily_points,
|
|
33
|
+
"repos_measured": len(ok),
|
|
34
|
+
"not_found": sum(1 for r in results if r.get("status") == "not_found"),
|
|
35
|
+
"capped": sum(1 for r in ok if any(r[k]["capped"] for k in ("full", "incremental", "issues"))),
|
|
36
|
+
"mean_full": _mean(full), "mean_incremental": _mean(incr), "mean_issues": _mean(issues),
|
|
37
|
+
"tiers": tiers, "by_bucket": buckets,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_markdown(s: dict) -> str:
|
|
42
|
+
lines = [
|
|
43
|
+
f"Repos measured: {s['repos_measured']} (not found: {s['not_found']}, hit page cap: {s['capped']})",
|
|
44
|
+
f"Mean points per repo: full history {s['mean_full']:.1f}, incremental {s['mean_incremental']:.1f}, "
|
|
45
|
+
f"open issues {s['mean_issues']:.1f}",
|
|
46
|
+
f"Daily budget assumed: {s['daily_points']} points",
|
|
47
|
+
"",
|
|
48
|
+
"| Tier size | First fill (days) | Refresh cycle (days) |",
|
|
49
|
+
"|---|---|---|",
|
|
50
|
+
]
|
|
51
|
+
lines += [f"| {t['tier']} | {t['first_fill_days']:.1f} | {t['refresh_cycle_days']:.1f} |" for t in s["tiers"]]
|
|
52
|
+
lines += ["", "| Star bucket | Repos | Full history median | Full history p90 | Issues median |",
|
|
53
|
+
"|---|---|---|---|---|"]
|
|
54
|
+
lines += [f"| {b} | {v['repos']} | {v['full_median']} | {v['full_p90']} | {v['issues_median']} |"
|
|
55
|
+
for b, v in s["by_bucket"].items()]
|
|
56
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
|
|
3
|
+
STAR_BUCKETS = [("50-199", "50..199"), ("200-999", "200..999"), ("1k-10k", "1000..9999"), ("10k+", ">=10000")]
|
|
4
|
+
CREATED_SLICES = ["<2016-01-01", "2016-01-01..2018-12-31", "2019-01-01..2021-12-31",
|
|
5
|
+
"2022-01-01..2023-12-31", ">=2024-01-01"]
|
|
6
|
+
|
|
7
|
+
SEARCH_QUERY = """
|
|
8
|
+
query($q: String!, $n: Int!) {
|
|
9
|
+
rateLimit { cost remaining resetAt }
|
|
10
|
+
search(query: $q, type: REPOSITORY, first: $n) {
|
|
11
|
+
repositoryCount
|
|
12
|
+
nodes { ... on Repository { nameWithOwner stargazerCount } }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_sample_queries(pushed_since: date) -> list[tuple[str, str]]:
|
|
19
|
+
# Mixing star ranges and creation years gives a spread of repo sizes and ages.
|
|
20
|
+
return [
|
|
21
|
+
(label, f"stars:{stars} created:{created} pushed:>={pushed_since.isoformat()} fork:false archived:false")
|
|
22
|
+
for label, stars in STAR_BUCKETS
|
|
23
|
+
for created in CREATED_SLICES
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def collect_sample(gql, queries: list[tuple[str, str]], per_query: int = 15) -> list[dict]:
|
|
28
|
+
seen: set[str] = set()
|
|
29
|
+
sample = []
|
|
30
|
+
for label, q in queries:
|
|
31
|
+
result = gql.query(SEARCH_QUERY, {"q": q, "n": per_query})
|
|
32
|
+
for node in result.data["search"]["nodes"]:
|
|
33
|
+
name = node.get("nameWithOwner")
|
|
34
|
+
if name and name not in seen:
|
|
35
|
+
seen.add(name)
|
|
36
|
+
sample.append({"repo": name, "stars": node["stargazerCount"], "bucket": label})
|
|
37
|
+
return sample
|
mergescout/owners.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
LOGIN = re.compile(r"(?<![\w.])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))(?![\w/.-]*\.\w)")
|
|
4
|
+
YAML_KEY = re.compile(r"^(approvers|reviewers|maintainers)\s*:\s*$", re.I)
|
|
5
|
+
YAML_ITEM = re.compile(r"^\s*-\s*([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\s*(#.*)?$")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def owner_logins(text: str) -> set[str]:
|
|
9
|
+
# "@alice" counts, "@org/team" and e-mail addresses do not; Kubernetes OWNERS files list plain names.
|
|
10
|
+
text = text or ""
|
|
11
|
+
found = {m.group(1).lower() for m in LOGIN.finditer(text) if text[m.end():m.end() + 1] != "/"}
|
|
12
|
+
in_list = False
|
|
13
|
+
for line in text.splitlines():
|
|
14
|
+
if YAML_KEY.match(line.strip()):
|
|
15
|
+
in_list = True
|
|
16
|
+
continue
|
|
17
|
+
item = YAML_ITEM.match(line) if in_list else None
|
|
18
|
+
if item:
|
|
19
|
+
found.add(item.group(1).lower())
|
|
20
|
+
elif line.strip() and not line.startswith((" ", "\t", "-")):
|
|
21
|
+
in_list = False
|
|
22
|
+
return found
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Check the crawler repo's tokens without crawling: python -m mergescout.setup_check"""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mergescout.config import load_config
|
|
8
|
+
from mergescout.github.graphql import GitHubGraphQL
|
|
9
|
+
from mergescout.github.identities import Identity, identities_from_env
|
|
10
|
+
from mergescout.github.releases import Releases
|
|
11
|
+
|
|
12
|
+
PROBE = 'query { rateLimit { cost remaining limit resetAt } repository(owner: "psf", name: "requests") { stargazerCount } }'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def check_identity(identity: Identity) -> tuple[bool, str]:
|
|
16
|
+
try:
|
|
17
|
+
data = identity.make_gql().query(PROBE).data
|
|
18
|
+
except Exception as exc:
|
|
19
|
+
return False, str(exc)
|
|
20
|
+
rate = data.get("rateLimit") or {}
|
|
21
|
+
if not (data.get("repository") or {}).get("stargazerCount"):
|
|
22
|
+
return False, "token works but cannot read public repositories"
|
|
23
|
+
return True, f"{rate.get('limit')} points/hour, {rate.get('remaining')} left"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_store(releases, tag: str = "crawl-state") -> tuple[bool, str]:
|
|
27
|
+
# Proves read and write with one tiny file that is removed straight away.
|
|
28
|
+
try:
|
|
29
|
+
if releases.get(tag) is None:
|
|
30
|
+
return False, f"can reach the repo but release '{tag}' is missing"
|
|
31
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
32
|
+
probe = Path(tmp) / "setup-check.txt"
|
|
33
|
+
probe.write_text("setup check, safe to delete\n", encoding="utf-8")
|
|
34
|
+
asset = releases.upload(tag, probe)
|
|
35
|
+
releases.delete_asset(asset)
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
return False, str(exc)
|
|
38
|
+
return True, "read and write both work"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main() -> int:
|
|
42
|
+
cfg = load_config()
|
|
43
|
+
results = []
|
|
44
|
+
if not cfg.github_token:
|
|
45
|
+
results.append(("personal token", False, "GITHUB_TOKEN is empty (secret MS_GITHUB_TOKEN)"))
|
|
46
|
+
else:
|
|
47
|
+
for identity in identities_from_env(cfg.github_token, max_wait=60, client=GitHubGraphQL):
|
|
48
|
+
results.append((f"{identity.name} token", *check_identity(identity)))
|
|
49
|
+
names = {i.name for i in identities_from_env(cfg.github_token, None)}
|
|
50
|
+
for expected in ("app", "actions"):
|
|
51
|
+
if expected not in names:
|
|
52
|
+
results.append((f"{expected} token", False, "not passed to this run"))
|
|
53
|
+
token, repo = os.environ.get("MS_RELEASE_TOKEN"), os.environ.get("MS_STORE_REPO")
|
|
54
|
+
if not token or not repo:
|
|
55
|
+
results.append(("data store", False, "MS_RELEASE_TOKEN or MS_STORE_REPO is empty (secret MS_STORE_TOKEN)"))
|
|
56
|
+
else:
|
|
57
|
+
results.append((f"data store ({repo})", *check_store(Releases(token, repo))))
|
|
58
|
+
for name, ok, detail in results:
|
|
59
|
+
print(f"{'OK ' if ok else 'FAIL'} {name}: {detail}", flush=True)
|
|
60
|
+
return 0 if all(ok for _, ok, _ in results) else 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
sys.exit(main())
|
|
File without changes
|