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,286 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import sqlite3
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pyarrow as pa
|
|
12
|
+
import pyarrow.parquet as pq
|
|
13
|
+
|
|
14
|
+
from mergescout.github.graphql import GraphQLError, RateLimited, TokenRejected, too_heavy
|
|
15
|
+
from mergescout.labels import OPPORTUNITY_LABELS
|
|
16
|
+
from mergescout.timeutil import parse_time
|
|
17
|
+
|
|
18
|
+
# GitHub search never returns more than this many results for one query.
|
|
19
|
+
MAX_RESULTS = 1000
|
|
20
|
+
FMT = "%Y-%m-%dT%H:%M:%SZ"
|
|
21
|
+
STAR_BOUNDS = [10, 20, 50, 100, 1000, 10000]
|
|
22
|
+
|
|
23
|
+
COUNT_QUERY = """
|
|
24
|
+
query($q: String!) {
|
|
25
|
+
rateLimit { cost remaining resetAt }
|
|
26
|
+
search(query: $q, type: REPOSITORY, first: 1) { repositoryCount }
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
REPO_QUERY = """
|
|
31
|
+
query($q: String!, $cursor: String, $n: Int!, $labels: [String!]) {
|
|
32
|
+
rateLimit { cost remaining resetAt }
|
|
33
|
+
search(query: $q, type: REPOSITORY, first: $n, after: $cursor) {
|
|
34
|
+
repositoryCount
|
|
35
|
+
pageInfo { hasNextPage endCursor }
|
|
36
|
+
nodes { ... on Repository {
|
|
37
|
+
databaseId nameWithOwner stargazerCount forkCount createdAt pushedAt
|
|
38
|
+
primaryLanguage { name } hasIssuesEnabled licenseInfo { spdxId }
|
|
39
|
+
issues(states: OPEN) { totalCount }
|
|
40
|
+
opp: issues(states: OPEN, labels: $labels) { totalCount }
|
|
41
|
+
} }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
SCHEMA = pa.schema([
|
|
47
|
+
("repo_id", pa.int64()),
|
|
48
|
+
("name", pa.string()),
|
|
49
|
+
("stars", pa.int32()),
|
|
50
|
+
("forks", pa.int32()),
|
|
51
|
+
("created_at", pa.timestamp("us", tz="UTC")),
|
|
52
|
+
("pushed_at", pa.timestamp("us", tz="UTC")),
|
|
53
|
+
("language", pa.string()),
|
|
54
|
+
("has_issues", pa.bool_()),
|
|
55
|
+
("license", pa.string()),
|
|
56
|
+
("open_issues", pa.int32()),
|
|
57
|
+
("open_prs", pa.int32()),
|
|
58
|
+
("opportunity_issues", pa.int32()),
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Slice:
|
|
64
|
+
stars_lo: int
|
|
65
|
+
stars_hi: int | None # None means no upper limit
|
|
66
|
+
created_lo: datetime
|
|
67
|
+
created_hi: datetime
|
|
68
|
+
pushed_lo: datetime | None = None # only repos pushed since then (active repos)
|
|
69
|
+
|
|
70
|
+
def query(self) -> str:
|
|
71
|
+
stars = f">={self.stars_lo}" if self.stars_hi is None else f"{self.stars_lo}..{self.stars_hi}"
|
|
72
|
+
span = f"{self.created_lo.strftime(FMT)}..{self.created_hi.strftime(FMT)}"
|
|
73
|
+
pushed = f" pushed:>={self.pushed_lo:%Y-%m-%d}" if self.pushed_lo else ""
|
|
74
|
+
return f"stars:{stars}{pushed} created:{span} fork:false archived:false"
|
|
75
|
+
|
|
76
|
+
def split(self) -> list["Slice"]:
|
|
77
|
+
# Split by stars while the range has more than one value, then by creation time.
|
|
78
|
+
if self.stars_hi is not None and self.stars_hi > self.stars_lo:
|
|
79
|
+
mid = (self.stars_lo + self.stars_hi) // 2
|
|
80
|
+
return [Slice(self.stars_lo, mid, self.created_lo, self.created_hi, self.pushed_lo),
|
|
81
|
+
Slice(mid + 1, self.stars_hi, self.created_lo, self.created_hi, self.pushed_lo)]
|
|
82
|
+
if self.created_hi - self.created_lo <= timedelta(minutes=1):
|
|
83
|
+
return []
|
|
84
|
+
mid = (self.created_lo + (self.created_hi - self.created_lo) / 2).replace(microsecond=0)
|
|
85
|
+
return [Slice(self.stars_lo, self.stars_hi, self.created_lo, mid, self.pushed_lo),
|
|
86
|
+
Slice(self.stars_lo, self.stars_hi, mid + timedelta(seconds=1), self.created_hi, self.pushed_lo)]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def initial_slices(min_stars: int, start: datetime, end: datetime, pushed_lo: datetime | None = None) -> list[Slice]:
|
|
90
|
+
bounds = [b for b in STAR_BOUNDS if b > min_stars]
|
|
91
|
+
lows = [min_stars] + bounds
|
|
92
|
+
highs = [b - 1 for b in bounds] + [None]
|
|
93
|
+
return [Slice(lo, hi, start, end, pushed_lo) for lo, hi in zip(lows, highs)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _row(n: dict) -> dict:
|
|
97
|
+
return {
|
|
98
|
+
"repo_id": n.get("databaseId"),
|
|
99
|
+
"name": n["nameWithOwner"],
|
|
100
|
+
"stars": n.get("stargazerCount"),
|
|
101
|
+
"forks": n.get("forkCount"),
|
|
102
|
+
"created_at": parse_time(n.get("createdAt")),
|
|
103
|
+
"pushed_at": parse_time(n.get("pushedAt")),
|
|
104
|
+
"language": (n.get("primaryLanguage") or {}).get("name"),
|
|
105
|
+
"has_issues": n.get("hasIssuesEnabled"),
|
|
106
|
+
"license": (n.get("licenseInfo") or {}).get("spdxId"),
|
|
107
|
+
"open_issues": (n.get("issues") or {}).get("totalCount"),
|
|
108
|
+
"open_prs": (n.get("pullRequests") or {}).get("totalCount"),
|
|
109
|
+
"opportunity_issues": (n.get("opp") or {}).get("totalCount"),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def fetch_slice(gql, sl: Slice, page: int = 50) -> tuple[list[dict] | None, int, int]:
|
|
114
|
+
# Returns (rows, total count, points spent); rows is None when the slice must be split first.
|
|
115
|
+
probe = gql.query(COUNT_QUERY, {"q": sl.query()})
|
|
116
|
+
cost = probe.cost
|
|
117
|
+
count = probe.data["search"]["repositoryCount"]
|
|
118
|
+
if count > MAX_RESULTS and sl.split():
|
|
119
|
+
return None, count, cost
|
|
120
|
+
rows: dict[int | str, dict] = {}
|
|
121
|
+
cursor = None
|
|
122
|
+
while count:
|
|
123
|
+
try:
|
|
124
|
+
result = gql.query(REPO_QUERY, {"q": sl.query(), "cursor": cursor, "n": page,
|
|
125
|
+
"labels": list(OPPORTUNITY_LABELS)})
|
|
126
|
+
except GraphQLError as exc:
|
|
127
|
+
# Big repos make pages heavy and slow; retry the same page with fewer repos.
|
|
128
|
+
if too_heavy(exc) and page > 10:
|
|
129
|
+
page = max(10, page // 2)
|
|
130
|
+
continue
|
|
131
|
+
raise
|
|
132
|
+
cost += result.cost
|
|
133
|
+
found = result.data["search"]
|
|
134
|
+
for n in found["nodes"]:
|
|
135
|
+
if n and n.get("nameWithOwner"):
|
|
136
|
+
row = _row(n)
|
|
137
|
+
rows[row["repo_id"] or row["name"]] = row
|
|
138
|
+
if not found["nodes"] or not found["pageInfo"]["hasNextPage"] or not found["pageInfo"]["endCursor"]:
|
|
139
|
+
break
|
|
140
|
+
if len(rows) >= MAX_RESULTS:
|
|
141
|
+
break
|
|
142
|
+
cursor = found["pageInfo"]["endCursor"]
|
|
143
|
+
return list(rows.values()), count, cost
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SweepState:
|
|
147
|
+
def __init__(self, path: Path, resume: bool = False):
|
|
148
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
self.lock = threading.Lock()
|
|
150
|
+
self.db = sqlite3.connect(path, check_same_thread=False)
|
|
151
|
+
with self.db:
|
|
152
|
+
self.db.execute(
|
|
153
|
+
"CREATE TABLE IF NOT EXISTS slices (key TEXT PRIMARY KEY, stars_lo INTEGER, stars_hi INTEGER, "
|
|
154
|
+
"created_lo TEXT, created_hi TEXT, status TEXT, rows INTEGER, cost INTEGER, error TEXT)"
|
|
155
|
+
)
|
|
156
|
+
self.db.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)")
|
|
157
|
+
if resume:
|
|
158
|
+
# Anything interrupted or failed last time gets another go.
|
|
159
|
+
self.db.execute("UPDATE slices SET status = 'queued' WHERE status IN ('running', 'failed')")
|
|
160
|
+
|
|
161
|
+
def setting(self, key: str, value: str | None = None) -> str | None:
|
|
162
|
+
with self.lock, self.db:
|
|
163
|
+
if value is not None:
|
|
164
|
+
self.db.execute("INSERT OR REPLACE INTO settings VALUES (?, ?)", (key, value))
|
|
165
|
+
row = self.db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
|
166
|
+
return row[0] if row else None
|
|
167
|
+
|
|
168
|
+
def seed(self, slices: list[Slice]) -> None:
|
|
169
|
+
with self.lock, self.db:
|
|
170
|
+
if self.db.execute("SELECT COUNT(*) FROM slices").fetchone()[0] == 0:
|
|
171
|
+
self._insert(slices)
|
|
172
|
+
|
|
173
|
+
def _insert(self, slices: list[Slice]) -> None:
|
|
174
|
+
self.db.executemany(
|
|
175
|
+
"INSERT OR IGNORE INTO slices VALUES (?, ?, ?, ?, ?, 'queued', NULL, NULL, NULL)",
|
|
176
|
+
[(s.query(), s.stars_lo, s.stars_hi, s.created_lo.strftime(FMT), s.created_hi.strftime(FMT))
|
|
177
|
+
for s in slices],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def claim(self) -> Slice | None:
|
|
181
|
+
pushed = parse_time(self.setting("pushed_after"))
|
|
182
|
+
with self.lock, self.db:
|
|
183
|
+
row = self.db.execute(
|
|
184
|
+
"SELECT key, stars_lo, stars_hi, created_lo, created_hi FROM slices "
|
|
185
|
+
"WHERE status = 'queued' ORDER BY stars_lo DESC LIMIT 1").fetchone()
|
|
186
|
+
if row is None:
|
|
187
|
+
return None
|
|
188
|
+
self.db.execute("UPDATE slices SET status = 'running' WHERE key = ?", (row[0],))
|
|
189
|
+
return Slice(row[1], row[2], parse_time(row[3]), parse_time(row[4]), pushed)
|
|
190
|
+
|
|
191
|
+
def done(self, sl: Slice, rows: int, cost: int) -> None:
|
|
192
|
+
with self.lock, self.db:
|
|
193
|
+
self.db.execute("UPDATE slices SET status = 'done', rows = ?, cost = ? WHERE key = ?",
|
|
194
|
+
(rows, cost, sl.query()))
|
|
195
|
+
|
|
196
|
+
def split(self, sl: Slice, children: list[Slice], cost: int) -> None:
|
|
197
|
+
with self.lock, self.db:
|
|
198
|
+
self._insert(children)
|
|
199
|
+
self.db.execute("UPDATE slices SET status = 'split', cost = ? WHERE key = ?", (cost, sl.query()))
|
|
200
|
+
|
|
201
|
+
def release(self, sl: Slice) -> None:
|
|
202
|
+
with self.lock, self.db:
|
|
203
|
+
self.db.execute("UPDATE slices SET status = 'queued' WHERE key = ?", (sl.query(),))
|
|
204
|
+
|
|
205
|
+
def fail(self, sl: Slice, error: str) -> None:
|
|
206
|
+
with self.lock, self.db:
|
|
207
|
+
self.db.execute("UPDATE slices SET status = 'failed', error = ? WHERE key = ?", (error[:300], sl.query()))
|
|
208
|
+
|
|
209
|
+
def active(self) -> int:
|
|
210
|
+
with self.lock:
|
|
211
|
+
return self.db.execute(
|
|
212
|
+
"SELECT COUNT(*) FROM slices WHERE status IN ('queued', 'running')").fetchone()[0]
|
|
213
|
+
|
|
214
|
+
def counts(self) -> dict:
|
|
215
|
+
with self.lock:
|
|
216
|
+
return dict(self.db.execute("SELECT status, COUNT(*) FROM slices GROUP BY status").fetchall())
|
|
217
|
+
|
|
218
|
+
def totals(self) -> tuple[int, int]:
|
|
219
|
+
with self.lock:
|
|
220
|
+
rows, cost = self.db.execute("SELECT COALESCE(SUM(rows), 0), COALESCE(SUM(cost), 0) FROM slices").fetchone()
|
|
221
|
+
return rows, cost
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _write(rows: list[dict], out: Path) -> None:
|
|
225
|
+
tmp = out.with_name(out.name + ".part")
|
|
226
|
+
pq.write_table(pa.Table.from_pylist(rows, schema=SCHEMA), tmp, compression="zstd")
|
|
227
|
+
os.replace(tmp, out)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def run_sweep(make_gql, state: SweepState, data_dir: Path, workers: int = 4,
|
|
231
|
+
log: Callable[..., None] = print) -> bool:
|
|
232
|
+
# make_gql is one client factory (used by every worker) or a list with one factory per worker.
|
|
233
|
+
# Returns True when slices are left because an identity hit its limit; the next run resumes them.
|
|
234
|
+
out_dir = data_dir / "basics"
|
|
235
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
factories = make_gql if isinstance(make_gql, list) else [make_gql] * workers
|
|
237
|
+
|
|
238
|
+
in_flight = [0]
|
|
239
|
+
flight_lock = threading.Lock()
|
|
240
|
+
limited = threading.Event()
|
|
241
|
+
|
|
242
|
+
def worker(factory) -> None:
|
|
243
|
+
gql = factory()
|
|
244
|
+
while True:
|
|
245
|
+
sl = state.claim()
|
|
246
|
+
if sl is None:
|
|
247
|
+
with flight_lock:
|
|
248
|
+
busy = in_flight[0]
|
|
249
|
+
if busy == 0:
|
|
250
|
+
return
|
|
251
|
+
time.sleep(0.5)
|
|
252
|
+
continue
|
|
253
|
+
with flight_lock:
|
|
254
|
+
in_flight[0] += 1
|
|
255
|
+
try:
|
|
256
|
+
rows, count, cost = fetch_slice(gql, sl)
|
|
257
|
+
if rows is None:
|
|
258
|
+
state.split(sl, sl.split(), cost)
|
|
259
|
+
else:
|
|
260
|
+
if rows:
|
|
261
|
+
_write(rows, out_dir / f"{hashlib.sha1(sl.query().encode()).hexdigest()[:16]}.parquet")
|
|
262
|
+
state.done(sl, len(rows), cost)
|
|
263
|
+
if len(rows) < min(count, MAX_RESULTS):
|
|
264
|
+
log(f"warning: {sl.query()} returned {len(rows)} of {count} repos")
|
|
265
|
+
except (RateLimited, TokenRejected) as exc:
|
|
266
|
+
# This worker's identity is out for now; the slice goes back for the others or the next run.
|
|
267
|
+
state.release(sl)
|
|
268
|
+
limited.set()
|
|
269
|
+
log(f"worker stops: {exc}")
|
|
270
|
+
return # the finally below still marks this slice as no longer in flight
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
# Anything that breaks after the fetch must not leave the slice stuck as running.
|
|
273
|
+
state.fail(sl, f"{type(exc).__name__}: {exc}")
|
|
274
|
+
log(f"failed {sl.query()}: {exc}")
|
|
275
|
+
finally:
|
|
276
|
+
with flight_lock:
|
|
277
|
+
in_flight[0] -= 1
|
|
278
|
+
total_rows, total_cost = state.totals()
|
|
279
|
+
log(f"{datetime.now(timezone.utc):%H:%M:%S} repos={total_rows:,} points={total_cost:,} {state.counts()}")
|
|
280
|
+
|
|
281
|
+
threads = [threading.Thread(target=worker, args=(f,), daemon=True) for f in factories]
|
|
282
|
+
for t in threads:
|
|
283
|
+
t.start()
|
|
284
|
+
for t in threads:
|
|
285
|
+
t.join()
|
|
286
|
+
return limited.is_set() and state.counts().get("queued", 0) > 0
|
|
File without changes
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from mergescout.timeutil import parse_time
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GraphQLError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NotFound(GraphQLError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RateLimited(GraphQLError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TokenRejected(GraphQLError):
|
|
26
|
+
# A token that stopped working (expired App token, revoked token): that identity steps aside.
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Gate:
|
|
31
|
+
# Shared by every client on one token: when GitHub says "wait", all of them wait, not just the one told.
|
|
32
|
+
def __init__(self, clock: Callable[[], float] = time.monotonic):
|
|
33
|
+
self.clock = clock
|
|
34
|
+
self.lock = threading.Lock()
|
|
35
|
+
self.until = 0.0
|
|
36
|
+
self.stats = {"requests": 0, "timeouts": 0, "rate_waits": 0, "waited_s": 0.0}
|
|
37
|
+
|
|
38
|
+
def note(self, key: str, amount: float = 1) -> None:
|
|
39
|
+
with self.lock:
|
|
40
|
+
self.stats[key] += amount
|
|
41
|
+
|
|
42
|
+
def summary(self) -> str:
|
|
43
|
+
s = self.stats
|
|
44
|
+
return (f"requests={s['requests']} timeouts={s['timeouts']} rate-limit waits={s['rate_waits']} "
|
|
45
|
+
f"waited={s['waited_s']:.0f}s")
|
|
46
|
+
|
|
47
|
+
def hold(self, seconds: float) -> None:
|
|
48
|
+
with self.lock:
|
|
49
|
+
self.until = max(self.until, self.clock() + seconds)
|
|
50
|
+
|
|
51
|
+
def remaining(self) -> float:
|
|
52
|
+
with self.lock:
|
|
53
|
+
return max(0.0, self.until - self.clock())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def too_heavy(exc: Exception) -> bool:
|
|
57
|
+
# GitHub's ways of saying a page was too big or too slow; callers retry with a smaller page.
|
|
58
|
+
msg = str(exc).lower()
|
|
59
|
+
return any(s in msg for s in ("resource limits", "timeout", "timed out", "kept failing (http 502",
|
|
60
|
+
"something went wrong", "not json"))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class GraphQLResult:
|
|
65
|
+
data: dict
|
|
66
|
+
cost: int
|
|
67
|
+
remaining: int
|
|
68
|
+
reset_at: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class GitHubGraphQL:
|
|
72
|
+
# GitHub Enterprise Server's endpoint when Actions names one.
|
|
73
|
+
URL = os.environ.get("GITHUB_GRAPHQL_URL") or "https://api.github.com/graphql"
|
|
74
|
+
RETRY_STATUSES = (500, 502, 503, 504)
|
|
75
|
+
TIMEOUT_STATUSES = (502, 504)
|
|
76
|
+
|
|
77
|
+
def __init__(self, token: str, *, client: httpx.Client | None = None, max_retries: int = 5,
|
|
78
|
+
reserve: int = 200, sleep: Callable[[float], None] = time.sleep,
|
|
79
|
+
now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
|
80
|
+
max_wait: float | Callable[[], float] | None = None,
|
|
81
|
+
timeout_retries: int = 1, gate: Gate | None = None):
|
|
82
|
+
self.client = client or httpx.Client(timeout=httpx.Timeout(60.0, connect=15.0))
|
|
83
|
+
# Tokens pasted into settings often carry a stray space or line break.
|
|
84
|
+
self.headers = {"Authorization": f"Bearer {token.strip()}", "User-Agent": "mergescout"}
|
|
85
|
+
self.max_retries = max_retries
|
|
86
|
+
self.reserve = reserve
|
|
87
|
+
self.sleep = sleep
|
|
88
|
+
self.now = now
|
|
89
|
+
# A crawl on paid runner minutes stops instead of sleeping until the hourly reset.
|
|
90
|
+
self.max_wait = max_wait
|
|
91
|
+
self.timeout_retries = timeout_retries
|
|
92
|
+
self.last_remaining: int | None = None
|
|
93
|
+
self.gate = gate or Gate()
|
|
94
|
+
|
|
95
|
+
def query(self, query: str, variables: dict | None = None, *, partial: bool = False) -> GraphQLResult:
|
|
96
|
+
timeouts = 0
|
|
97
|
+
for attempt in range(self.max_retries + 1):
|
|
98
|
+
shared = self.gate.remaining()
|
|
99
|
+
if shared > 0:
|
|
100
|
+
self._pause(shared)
|
|
101
|
+
self.gate.note("requests")
|
|
102
|
+
try:
|
|
103
|
+
resp = self.client.post(self.URL, json={"query": query, "variables": variables or {}},
|
|
104
|
+
headers=self.headers)
|
|
105
|
+
except httpx.LocalProtocolError as exc:
|
|
106
|
+
# The request itself is malformed (usually a bad character in the token); retrying cannot help.
|
|
107
|
+
raise GraphQLError(f"GitHub request could not be sent ({exc}); check the token for spaces or "
|
|
108
|
+
f"line breaks") from exc
|
|
109
|
+
except httpx.TransportError as exc:
|
|
110
|
+
# Wi-Fi drops, DNS hiccups and timeouts are retried like server errors.
|
|
111
|
+
if attempt == self.max_retries:
|
|
112
|
+
raise GraphQLError(f"GitHub unreachable (network error: {exc}) after {attempt} retries") from exc
|
|
113
|
+
self.sleep(min(60, 2 ** attempt * 2))
|
|
114
|
+
continue
|
|
115
|
+
if resp.status_code == 401:
|
|
116
|
+
raise TokenRejected("GitHub rejected the token (401). Check GITHUB_TOKEN.")
|
|
117
|
+
if resp.status_code in self.TIMEOUT_STATUSES:
|
|
118
|
+
self.gate.note("timeouts")
|
|
119
|
+
# GitHub stops queries after 10 s; retry once, then let the caller ask for a smaller page.
|
|
120
|
+
if timeouts >= self.timeout_retries:
|
|
121
|
+
raise GraphQLError(f"GitHub timed out (HTTP {resp.status_code}); the query may be too heavy")
|
|
122
|
+
timeouts += 1
|
|
123
|
+
if resp.status_code in self.RETRY_STATUSES or self._is_rate_limited(resp):
|
|
124
|
+
if attempt == self.max_retries:
|
|
125
|
+
raise GraphQLError(f"GitHub kept failing (HTTP {resp.status_code}) after {attempt} retries")
|
|
126
|
+
wait = self._wait_for(resp, attempt)
|
|
127
|
+
if self._is_rate_limited(resp):
|
|
128
|
+
self.gate.hold(wait)
|
|
129
|
+
self.gate.note("rate_waits")
|
|
130
|
+
self._pause(wait)
|
|
131
|
+
continue
|
|
132
|
+
if resp.status_code != 200:
|
|
133
|
+
raise GraphQLError(f"GitHub returned HTTP {resp.status_code}: {resp.text[:200]}")
|
|
134
|
+
try:
|
|
135
|
+
body = resp.json()
|
|
136
|
+
except ValueError as exc:
|
|
137
|
+
raise GraphQLError(f"GitHub returned a body that is not JSON: {resp.text[:200]}") from exc
|
|
138
|
+
errors = body.get("errors") or []
|
|
139
|
+
data = body.get("data") or {}
|
|
140
|
+
if errors:
|
|
141
|
+
types = {e.get("type") for e in errors}
|
|
142
|
+
if "RATE_LIMITED" in types and attempt < self.max_retries:
|
|
143
|
+
wait = self._seconds_until((data.get("rateLimit") or {}).get("resetAt"), fallback=60)
|
|
144
|
+
self.gate.hold(wait)
|
|
145
|
+
self.gate.note("rate_waits")
|
|
146
|
+
self._pause(wait)
|
|
147
|
+
continue
|
|
148
|
+
top = [e for e in errors if e.get("type") == "NOT_FOUND" and e.get("path") == ["repository"]]
|
|
149
|
+
if top and (partial or len(top) == len(errors)):
|
|
150
|
+
raise NotFound(top[0].get("message", "not found"))
|
|
151
|
+
# With partial, items that no longer exist come back as null instead of failing the whole page.
|
|
152
|
+
if not (partial and all(e.get("type") == "NOT_FOUND" for e in errors)):
|
|
153
|
+
raise GraphQLError("; ".join(sorted({e.get("message", "unknown error") for e in errors})))
|
|
154
|
+
rate = data.get("rateLimit")
|
|
155
|
+
if rate is None:
|
|
156
|
+
raise ValueError("Every query must request rateLimit { cost remaining resetAt }")
|
|
157
|
+
result = GraphQLResult(data, rate["cost"], rate["remaining"], rate["resetAt"])
|
|
158
|
+
self.last_remaining = result.remaining
|
|
159
|
+
if result.remaining < self.reserve:
|
|
160
|
+
wait = self._seconds_until(result.reset_at, fallback=0)
|
|
161
|
+
limit = self._wait_limit()
|
|
162
|
+
if limit is None or wait <= limit:
|
|
163
|
+
self.gate.hold(wait)
|
|
164
|
+
self.sleep(wait)
|
|
165
|
+
return result
|
|
166
|
+
raise GraphQLError("unreachable")
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _is_rate_limited(resp: httpx.Response) -> bool:
|
|
170
|
+
if resp.status_code == 429:
|
|
171
|
+
return True
|
|
172
|
+
return resp.status_code == 403 and (
|
|
173
|
+
"rate limit" in resp.text.lower() or resp.headers.get("x-ratelimit-remaining") == "0")
|
|
174
|
+
|
|
175
|
+
def _wait_for(self, resp: httpx.Response, attempt: int) -> float:
|
|
176
|
+
retry_after = resp.headers.get("retry-after")
|
|
177
|
+
if retry_after and retry_after.isdigit():
|
|
178
|
+
return int(retry_after)
|
|
179
|
+
if resp.headers.get("x-ratelimit-remaining") == "0" and resp.headers.get("x-ratelimit-reset", "").isdigit():
|
|
180
|
+
reset = datetime.fromtimestamp(int(resp.headers["x-ratelimit-reset"]), tz=timezone.utc)
|
|
181
|
+
return max(0.0, (reset - self.now()).total_seconds() + 5)
|
|
182
|
+
if self._is_rate_limited(resp):
|
|
183
|
+
# GitHub asks for at least a minute after a secondary limit.
|
|
184
|
+
return 60
|
|
185
|
+
return min(60, 2 ** attempt * 2)
|
|
186
|
+
|
|
187
|
+
def _wait_limit(self) -> float | None:
|
|
188
|
+
# max_wait may be a function, so a crawl can sit out any wait that still fits in its time window.
|
|
189
|
+
return self.max_wait() if callable(self.max_wait) else self.max_wait
|
|
190
|
+
|
|
191
|
+
def _pause(self, seconds: float) -> None:
|
|
192
|
+
limit = self._wait_limit()
|
|
193
|
+
if limit is not None and seconds > limit:
|
|
194
|
+
raise RateLimited(f"GitHub asks to wait {int(seconds)} s for the rate limit (allowed {int(limit)} s)")
|
|
195
|
+
self.gate.note("waited_s", seconds)
|
|
196
|
+
self.sleep(seconds)
|
|
197
|
+
|
|
198
|
+
def _seconds_until(self, reset_at: str | None, fallback: float) -> float:
|
|
199
|
+
reset = parse_time(reset_at)
|
|
200
|
+
if reset is None:
|
|
201
|
+
return fallback
|
|
202
|
+
return max(0.0, (reset - self.now()).total_seconds() + 5)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from mergescout.github.graphql import Gate, GitHubGraphQL
|
|
6
|
+
|
|
7
|
+
# Hourly points relative to a personal token; GitHub reported 5,000 for all three on 2026-10-15.
|
|
8
|
+
SHARES = {"personal": 1.0, "app": 1.0, "actions": 1.0}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Identity:
|
|
13
|
+
name: str
|
|
14
|
+
make_gql: Callable
|
|
15
|
+
gate: Gate
|
|
16
|
+
share: float = 1.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def slots(in_flight: int, share: float) -> int:
|
|
20
|
+
return max(1, round(in_flight * share))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def identities_from_env(primary_token: str, max_wait: float | None, client=GitHubGraphQL) -> list[Identity]:
|
|
24
|
+
# Each identity has its own limits, so each gets its own shared wait; the same token is never used twice.
|
|
25
|
+
found = [("personal", primary_token), ("app", os.environ.get("MS_APP_TOKEN")),
|
|
26
|
+
("actions", os.environ.get("MS_ACTIONS_TOKEN"))]
|
|
27
|
+
out, seen = [], set()
|
|
28
|
+
for name, token in found:
|
|
29
|
+
token = (token or "").strip()
|
|
30
|
+
if not token or token in seen:
|
|
31
|
+
continue
|
|
32
|
+
seen.add(token)
|
|
33
|
+
gate = Gate()
|
|
34
|
+
out.append(Identity(name, lambda t=token, g=gate: client(t, max_wait=max_wait, gate=g), gate, SHARES[name]))
|
|
35
|
+
return out
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
API = "https://api.github.com"
|
|
8
|
+
UPLOADS = "https://uploads.github.com"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ReleaseError(RuntimeError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Releases:
|
|
16
|
+
# Release assets in our own repo are the durable store for crawl data and state backups.
|
|
17
|
+
def __init__(self, token: str | None, repo: str, client: httpx.Client | None = None, sleep=time.sleep, clock=time.time,
|
|
18
|
+
max_wait: float = 600): # the crawl job has 70 min: 52 crawling, a few to push
|
|
19
|
+
self.repo, self.sleep, self.clock, self.max_wait = repo, sleep, clock, max_wait
|
|
20
|
+
self.client = client or httpx.Client(timeout=httpx.Timeout(300.0, connect=15.0), follow_redirects=True)
|
|
21
|
+
self.headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28",
|
|
22
|
+
"User-Agent": "mergescout"}
|
|
23
|
+
# Public data downloads need no token; private repos and uploads do.
|
|
24
|
+
if token and token.strip():
|
|
25
|
+
self.headers["Authorization"] = f"Bearer {token.strip()}"
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def _check(resp: httpx.Response, what: str) -> httpx.Response:
|
|
29
|
+
if resp.status_code >= 400:
|
|
30
|
+
raise ReleaseError(f"{what} failed: HTTP {resp.status_code} {resp.text[:200]}")
|
|
31
|
+
return resp
|
|
32
|
+
|
|
33
|
+
def _limited_wait(self, resp: httpx.Response) -> float | None:
|
|
34
|
+
# Seconds until the account's hourly limit resets, when that is why the request failed and the wait is short.
|
|
35
|
+
if resp.status_code not in (403, 429) or resp.headers.get("x-ratelimit-remaining") != "0":
|
|
36
|
+
return None
|
|
37
|
+
wait = float(resp.headers.get("x-ratelimit-reset") or 0) - self.clock() + 5
|
|
38
|
+
return wait if 0 < wait <= self.max_wait else None
|
|
39
|
+
|
|
40
|
+
def get(self, tag: str) -> dict | None:
|
|
41
|
+
resp = self.client.get(f"{API}/repos/{self.repo}/releases/tags/{tag}", headers=self.headers)
|
|
42
|
+
if resp.status_code == 404:
|
|
43
|
+
return None
|
|
44
|
+
return self._check(resp, f"reading release {tag}").json()
|
|
45
|
+
|
|
46
|
+
def ensure(self, tag: str) -> dict:
|
|
47
|
+
found = self.get(tag)
|
|
48
|
+
if found:
|
|
49
|
+
return found
|
|
50
|
+
body = {"tag_name": tag, "name": tag, "body": "Automated MergeScout data store.", "prerelease": True,
|
|
51
|
+
"make_latest": "false"}
|
|
52
|
+
resp = self.client.post(f"{API}/repos/{self.repo}/releases", headers=self.headers, json=body)
|
|
53
|
+
return self._check(resp, f"creating release {tag}").json()
|
|
54
|
+
|
|
55
|
+
def assets(self, release: dict) -> list[dict]:
|
|
56
|
+
out, page = [], 1
|
|
57
|
+
while True:
|
|
58
|
+
resp = self.client.get(f"{API}/repos/{self.repo}/releases/{release['id']}/assets", headers=self.headers,
|
|
59
|
+
params={"per_page": 100, "page": page})
|
|
60
|
+
batch = self._check(resp, "listing release assets").json()
|
|
61
|
+
out.extend(batch)
|
|
62
|
+
if len(batch) < 100:
|
|
63
|
+
return out
|
|
64
|
+
page += 1
|
|
65
|
+
|
|
66
|
+
def upload(self, tag: str, path: Path, name: str | None = None) -> dict:
|
|
67
|
+
release = self.ensure(tag)
|
|
68
|
+
name = name or path.name
|
|
69
|
+
existing = {a["name"]: a for a in self.assets(release)}
|
|
70
|
+
# A file being replaced is uploaded under a temporary name first, so a failed upload keeps the old copy.
|
|
71
|
+
target = f"{name}.new" if name in existing else name
|
|
72
|
+
if target in existing:
|
|
73
|
+
self.delete_asset(existing[target])
|
|
74
|
+
headers = {**self.headers, "Content-Type": "application/octet-stream",
|
|
75
|
+
"Content-Length": str(path.stat().st_size)}
|
|
76
|
+
for attempt in range(2):
|
|
77
|
+
with path.open("rb") as f:
|
|
78
|
+
resp = self.client.post(f"{UPLOADS}/repos/{self.repo}/releases/{release['id']}/assets",
|
|
79
|
+
params={"name": target}, headers=headers, content=f)
|
|
80
|
+
# The hour's work is in this file: wait for the reset once rather than lose it (real failure 2026-10-20).
|
|
81
|
+
wait = self._limited_wait(resp) if attempt == 0 else None
|
|
82
|
+
if wait is None:
|
|
83
|
+
break
|
|
84
|
+
self.sleep(wait)
|
|
85
|
+
uploaded = self._check(resp, f"uploading {name}").json()
|
|
86
|
+
if target == name:
|
|
87
|
+
return uploaded
|
|
88
|
+
self.delete_asset(existing[name])
|
|
89
|
+
resp = self.client.patch(f"{API}/repos/{self.repo}/releases/assets/{uploaded['id']}", headers=self.headers,
|
|
90
|
+
json={"name": name})
|
|
91
|
+
return self._check(resp, f"renaming {target}").json()
|
|
92
|
+
|
|
93
|
+
def list_releases(self) -> list[dict]:
|
|
94
|
+
out, page = [], 1
|
|
95
|
+
while True:
|
|
96
|
+
resp = self.client.get(f"{API}/repos/{self.repo}/releases", headers=self.headers,
|
|
97
|
+
params={"per_page": 100, "page": page})
|
|
98
|
+
batch = self._check(resp, "listing releases").json()
|
|
99
|
+
out.extend(batch)
|
|
100
|
+
if len(batch) < 100:
|
|
101
|
+
return out
|
|
102
|
+
page += 1
|
|
103
|
+
|
|
104
|
+
def delete_asset(self, asset: dict) -> None:
|
|
105
|
+
self._check(self.client.delete(f"{API}/repos/{self.repo}/releases/assets/{asset['id']}", headers=self.headers),
|
|
106
|
+
f"deleting {asset.get('name', asset['id'])}")
|
|
107
|
+
|
|
108
|
+
def delete_release(self, release: dict) -> None:
|
|
109
|
+
self._check(self.client.delete(f"{API}/repos/{self.repo}/releases/{release['id']}", headers=self.headers),
|
|
110
|
+
f"deleting release {release['tag_name']}")
|
|
111
|
+
# The tag stays behind otherwise; a missing tag is fine.
|
|
112
|
+
resp = self.client.delete(f"{API}/repos/{self.repo}/git/refs/tags/{release['tag_name']}", headers=self.headers)
|
|
113
|
+
if resp.status_code not in (204, 404, 422):
|
|
114
|
+
self._check(resp, f"deleting tag {release['tag_name']}")
|
|
115
|
+
|
|
116
|
+
def download(self, tag: str, name: str, dest: Path) -> bool:
|
|
117
|
+
release = self.get(tag)
|
|
118
|
+
asset = next((a for a in self.assets(release) if a["name"] == name), None) if release else None
|
|
119
|
+
if asset is None:
|
|
120
|
+
return False
|
|
121
|
+
self.download_asset(asset, dest)
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
def download_asset(self, asset: dict, dest: Path) -> None:
|
|
125
|
+
name = asset.get("name", asset["id"])
|
|
126
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
part = dest.with_name(dest.name + ".part")
|
|
128
|
+
headers = {**self.headers, "Accept": "application/octet-stream"}
|
|
129
|
+
with self.client.stream("GET", f"{API}/repos/{self.repo}/releases/assets/{asset['id']}",
|
|
130
|
+
headers=headers) as resp:
|
|
131
|
+
if resp.status_code >= 400:
|
|
132
|
+
resp.read()
|
|
133
|
+
self._check(resp, f"downloading {name}")
|
|
134
|
+
with part.open("wb") as f:
|
|
135
|
+
for chunk in resp.iter_bytes():
|
|
136
|
+
f.write(chunk)
|
|
137
|
+
os.replace(part, dest)
|