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,176 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mergescout.crawl.fetch import cutoff, fetch_repo
|
|
8
|
+
from mergescout.crawl.outcomes import apply_outcomes, plan_outcomes
|
|
9
|
+
from mergescout.crawl.state import CRAWL_VERSION, CrawlState
|
|
10
|
+
from mergescout.crawl.writer import BatchWriter
|
|
11
|
+
from mergescout.github.graphql import Gate, RateLimited, TokenRejected
|
|
12
|
+
from mergescout.github.identities import Identity, slots
|
|
13
|
+
from mergescout.labels import has_opportunity
|
|
14
|
+
from mergescout.timeutil import parse_time
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_hot(issues: list[dict]) -> bool:
|
|
18
|
+
return any(has_opportunity(i.get("labels") or []) for i in issues if i.get("state") == "OPEN")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def changed_since(repo: dict, now: datetime, full_every_days: int) -> datetime | None:
|
|
22
|
+
# Refreshes ask only for changes since the last crawl (two hours of overlap); a full crawl comes back monthly.
|
|
23
|
+
last, full = parse_time(repo.get("last_done_at")), parse_time(repo.get("last_full_at"))
|
|
24
|
+
outdated = (repo.get("crawl_version") or CRAWL_VERSION) < CRAWL_VERSION
|
|
25
|
+
if last is None or full is None or outdated or now - full >= timedelta(days=full_every_days):
|
|
26
|
+
return None
|
|
27
|
+
return last - timedelta(hours=2)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _Lane:
|
|
31
|
+
# One identity's share of the run: its own parallel slots, points left and reason for stepping aside.
|
|
32
|
+
def __init__(self, identity: Identity, in_flight: int):
|
|
33
|
+
self.name, self.make_gql, self.gate = identity.name, identity.make_gql, identity.gate
|
|
34
|
+
self.max_slots = slots(in_flight, identity.share)
|
|
35
|
+
self.slots = self.lowest = self.max_slots
|
|
36
|
+
self.busy, self.repos, self.points = 0, 0, 0
|
|
37
|
+
self.remaining: int | None = None
|
|
38
|
+
self.out: str | None = None
|
|
39
|
+
self.seen_waits, self.calm = 0, 0
|
|
40
|
+
|
|
41
|
+
def pace(self, log) -> None:
|
|
42
|
+
# Halve parallel requests when GitHub asked this identity to slow down; add one back after 20 calm repos.
|
|
43
|
+
waits = self.gate.stats["rate_waits"]
|
|
44
|
+
if waits > self.seen_waits:
|
|
45
|
+
self.seen_waits, self.calm = waits, 0
|
|
46
|
+
if self.slots > 1:
|
|
47
|
+
self.slots = max(1, self.slots // 2)
|
|
48
|
+
self.lowest = min(self.lowest, self.slots)
|
|
49
|
+
log(f"{self.name}: GitHub asked to slow down; parallel requests now {self.slots}")
|
|
50
|
+
else:
|
|
51
|
+
self.calm += 1
|
|
52
|
+
if self.calm >= 20 and self.slots < self.max_slots:
|
|
53
|
+
self.slots, self.calm = self.slots + 1, 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_crawl(state: CrawlState, make_gql, data_dir: Path, run_id: str, *, minutes: float, in_flight: int = 12,
|
|
57
|
+
reserve: int = 300, limit: int | None = None, flush_every: int = 250, hot_days: int = 1,
|
|
58
|
+
cold_days: int = 7, issue_days: int = 90, full_every_days: int = 30,
|
|
59
|
+
now=lambda: datetime.now(timezone.utc), log=print,
|
|
60
|
+
clock=time.monotonic) -> dict:
|
|
61
|
+
# make_gql is one client factory, or a list of identities that share the queue, each with its own limits.
|
|
62
|
+
identities = make_gql if isinstance(make_gql, list) else [Identity("default", make_gql, Gate())]
|
|
63
|
+
lanes = [_Lane(i, in_flight) for i in identities]
|
|
64
|
+
state.start_run(run_id, "crawl")
|
|
65
|
+
writer = BatchWriter(data_dir, run_id)
|
|
66
|
+
local = threading.local()
|
|
67
|
+
started = clock()
|
|
68
|
+
stats = {"repos": 0, "points": 0, "failed": 0, "gone": 0}
|
|
69
|
+
pending: list[tuple] = []
|
|
70
|
+
stop = None
|
|
71
|
+
|
|
72
|
+
def work(lane: _Lane, repo):
|
|
73
|
+
# Runs in a pool thread; each thread keeps one client per identity. Never raises.
|
|
74
|
+
clients = local.__dict__.setdefault("clients", {})
|
|
75
|
+
if lane.name not in clients:
|
|
76
|
+
clients[lane.name] = lane.make_gql()
|
|
77
|
+
gql = clients[lane.name]
|
|
78
|
+
try:
|
|
79
|
+
since = changed_since(repo, now(), full_every_days)
|
|
80
|
+
track = state.open_issue_numbers(repo["repo_id"]) if since is None else []
|
|
81
|
+
bundle = fetch_repo(gql, repo["repo_id"], repo["name"], now(), issue_days=issue_days, track=track,
|
|
82
|
+
changed_since=since)
|
|
83
|
+
return repo, bundle, None, getattr(gql, "last_remaining", None)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
return repo, None, exc, getattr(gql, "last_remaining", None)
|
|
86
|
+
|
|
87
|
+
def flush():
|
|
88
|
+
# Repos count as done only once their rows are safely on disk.
|
|
89
|
+
writer.flush()
|
|
90
|
+
for repo_id, points, prs, issues, days, keep, forget, full in pending:
|
|
91
|
+
apply_outcomes(state, repo_id, keep, forget)
|
|
92
|
+
state.done(repo_id, run_id, points, prs, issues, refresh_days=days, full=full)
|
|
93
|
+
pending.clear()
|
|
94
|
+
|
|
95
|
+
with ThreadPoolExecutor(max_workers=sum(lane.max_slots for lane in lanes)) as pool:
|
|
96
|
+
futures: dict = {}
|
|
97
|
+
while True:
|
|
98
|
+
if stop is None:
|
|
99
|
+
if (clock() - started) / 60 >= minutes:
|
|
100
|
+
stop = "time"
|
|
101
|
+
elif limit is not None and stats["repos"] + len(futures) >= limit:
|
|
102
|
+
stop = "limit"
|
|
103
|
+
elif all(lane.out for lane in lanes):
|
|
104
|
+
stop = "budget" if all(lane.out == "budget" for lane in lanes) else "rate_limit"
|
|
105
|
+
if stop is None:
|
|
106
|
+
claimed = False
|
|
107
|
+
for lane in lanes:
|
|
108
|
+
room = lane.slots - lane.busy
|
|
109
|
+
if limit is not None:
|
|
110
|
+
room = min(room, limit - stats["repos"] - len(futures))
|
|
111
|
+
if lane.out or room <= 0:
|
|
112
|
+
continue
|
|
113
|
+
for repo in state.claim(room, run_id):
|
|
114
|
+
futures[pool.submit(work, lane, repo)] = lane
|
|
115
|
+
lane.busy += 1
|
|
116
|
+
claimed = True
|
|
117
|
+
if not claimed and not futures:
|
|
118
|
+
stop = "empty"
|
|
119
|
+
if not futures:
|
|
120
|
+
break
|
|
121
|
+
finished, _ = wait(list(futures), return_when=FIRST_COMPLETED)
|
|
122
|
+
for f in finished:
|
|
123
|
+
lane = futures.pop(f)
|
|
124
|
+
lane.busy -= 1
|
|
125
|
+
repo, bundle, exc, left = f.result()
|
|
126
|
+
lane.pace(log)
|
|
127
|
+
if left is not None:
|
|
128
|
+
lane.remaining = left
|
|
129
|
+
if left < reserve and lane.out is None:
|
|
130
|
+
lane.out = "budget"
|
|
131
|
+
log(f"{lane.name}: points nearly used up ({left} left); it stops taking repos")
|
|
132
|
+
if isinstance(exc, (RateLimited, TokenRejected)):
|
|
133
|
+
state.release(repo["repo_id"])
|
|
134
|
+
if lane.out not in ("rate_limit", "rejected"):
|
|
135
|
+
lane.out = "rejected" if isinstance(exc, TokenRejected) else "rate_limit"
|
|
136
|
+
why = "its token was rejected" if lane.out == "rejected" else "GitHub's rate limit"
|
|
137
|
+
log(f"{lane.name}: stops on {why} ({exc})")
|
|
138
|
+
elif exc is not None:
|
|
139
|
+
state.fail(repo["repo_id"], run_id, f"{type(exc).__name__}: {exc}")
|
|
140
|
+
stats["failed"] += 1
|
|
141
|
+
log(f"failed {repo['name']}: {exc}")
|
|
142
|
+
elif bundle.status == "gone":
|
|
143
|
+
state.gone(repo["repo_id"], run_id, bundle.error or "gone")
|
|
144
|
+
stats["gone"] += 1
|
|
145
|
+
else:
|
|
146
|
+
if bundle.meta:
|
|
147
|
+
writer.add("repo_meta", [bundle.meta])
|
|
148
|
+
writer.add("prs", bundle.prs)
|
|
149
|
+
writer.add("issues", bundle.issues)
|
|
150
|
+
writer.add("comments", bundle.comments)
|
|
151
|
+
writer.add("closed_issues", bundle.closed_issues)
|
|
152
|
+
today = now()
|
|
153
|
+
events, keep, forget = plan_outcomes(state, repo["repo_id"], bundle.issues, today.date().isoformat(),
|
|
154
|
+
cutoff(today, issue_days), bundle.deleted_issues)
|
|
155
|
+
writer.add("issue_events", events)
|
|
156
|
+
hot = is_hot(bundle.issues) or state.has_hot_tracked(repo["repo_id"])
|
|
157
|
+
days = hot_days if hot else cold_days
|
|
158
|
+
pending.append((repo["repo_id"], bundle.points, len(bundle.prs), len(bundle.issues), days, keep,
|
|
159
|
+
forget, bundle.full))
|
|
160
|
+
stats["repos"] += 1
|
|
161
|
+
stats["points"] += bundle.points
|
|
162
|
+
lane.repos += 1
|
|
163
|
+
lane.points += bundle.points
|
|
164
|
+
if len(pending) >= flush_every:
|
|
165
|
+
flush()
|
|
166
|
+
flush()
|
|
167
|
+
reason = stop or "empty"
|
|
168
|
+
state.finish_run(run_id, stats["points"], stats["repos"], reason)
|
|
169
|
+
per = {lane.name: {"repos": lane.repos, "points": lane.points, "left": lane.remaining, "out": lane.out,
|
|
170
|
+
"slots": lane.slots, "lowest_slots": lane.lowest} for lane in lanes}
|
|
171
|
+
log(f"run {run_id}: repos={stats['repos']} points={stats['points']} failed={stats['failed']} "
|
|
172
|
+
f"gone={stats['gone']} reason={reason} queue={state.counts()}")
|
|
173
|
+
for name, s in per.items():
|
|
174
|
+
log(f" {name}: repos={s['repos']} points={s['points']} left={s['left']} slots={s['slots']} "
|
|
175
|
+
f"(lowest {s['lowest_slots']}) stopped={s['out'] or 'no'}")
|
|
176
|
+
return {**stats, "reason": reason, "identities": per}
|
mergescout/crawl/seed.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pyarrow.compute as pc
|
|
6
|
+
import pyarrow.dataset as ds
|
|
7
|
+
|
|
8
|
+
from mergescout.crawl.state import CrawlState
|
|
9
|
+
from mergescout.discover.sweep import SCHEMA
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def active_repos_from_sweep(data_dir: Path, min_stars: int, active_days: int, now: datetime) -> list[dict]:
|
|
13
|
+
base = data_dir / "discover" / "basics"
|
|
14
|
+
if not base.exists() or not any(base.glob("*.parquet")):
|
|
15
|
+
return []
|
|
16
|
+
# The fixed schema lets older sweep files without newer columns load as nulls.
|
|
17
|
+
table = ds.dataset(str(base), format="parquet", schema=SCHEMA).to_table(
|
|
18
|
+
filter=(pc.field("stars") >= min_stars) & (pc.field("pushed_at") >= now - timedelta(days=active_days)))
|
|
19
|
+
best: dict[int, dict] = {}
|
|
20
|
+
for r in table.to_pylist():
|
|
21
|
+
# A repo seen in two slices (stars moved mid-sweep) keeps the higher count.
|
|
22
|
+
if r["repo_id"] is not None and (r["repo_id"] not in best or r["stars"] > best[r["repo_id"]]["stars"]):
|
|
23
|
+
best[r["repo_id"]] = r
|
|
24
|
+
return sorted(best.values(), key=lambda r: -r["stars"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def expected_cost(repo: dict) -> int:
|
|
28
|
+
# Points a first crawl will likely cost: settings, PR pages (busier with stars), 2 points per 50 open issues.
|
|
29
|
+
stars = repo.get("stars") or 0
|
|
30
|
+
pr_pages = 6 if stars >= 10000 else 3 if stars >= 1000 else 1
|
|
31
|
+
issue_pages = max(1, math.ceil(min(repo.get("open_issues") or 0, 200) / 50))
|
|
32
|
+
return 1 + pr_pages + 2 * issue_pages
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def priority(repo: dict) -> int:
|
|
36
|
+
# Beginner issues per expected point, so the daily budget covers the most candidate issues first.
|
|
37
|
+
beginner = min(repo.get("opportunity_issues") or 0, 20)
|
|
38
|
+
if beginner:
|
|
39
|
+
value = beginner
|
|
40
|
+
elif repo.get("has_issues") and (repo.get("open_issues") or 0) > 0:
|
|
41
|
+
value = 0.5
|
|
42
|
+
else:
|
|
43
|
+
value = 0.1
|
|
44
|
+
return round(1000 * value / expected_cost(repo))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Watched repos (a list of owner/name lines) are crawled before everything else, so their history stays current.
|
|
48
|
+
WATCHED = 1_000_000
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_watch(path: Path | None) -> set[str]:
|
|
52
|
+
if not path or not Path(path).exists():
|
|
53
|
+
return set()
|
|
54
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
55
|
+
return {x.strip().lower() for x in lines if x.strip() and not x.strip().startswith("#")}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def apply_watch(rows: list[dict], watch: set[str]) -> int:
|
|
59
|
+
hits = 0
|
|
60
|
+
for r in rows:
|
|
61
|
+
if r["name"].lower() in watch:
|
|
62
|
+
r["priority"], hits = WATCHED, hits + 1
|
|
63
|
+
return hits
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def seed_queue(state: CrawlState, data_dir: Path, now: datetime, min_stars: int = 100, active_days: int = 90,
|
|
67
|
+
log=print, watch: set[str] | None = None) -> dict:
|
|
68
|
+
active = active_repos_from_sweep(data_dir, min_stars, active_days, now)
|
|
69
|
+
rows = [{"repo_id": r["repo_id"], "name": r["name"], "stars": r["stars"],
|
|
70
|
+
"pushed_at": r["pushed_at"].isoformat() if r["pushed_at"] else None, "priority": priority(r)}
|
|
71
|
+
for r in active]
|
|
72
|
+
watched = apply_watch(rows, watch or set())
|
|
73
|
+
added = state.upsert_repos(rows)
|
|
74
|
+
out = {"active": len(active), "opportunity_repos": sum(1 for r in active if r.get("opportunity_issues")),
|
|
75
|
+
"added": added, "watched": watched}
|
|
76
|
+
log(f"seed: {out}")
|
|
77
|
+
return out
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import threading
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mergescout.labels import has_opportunity
|
|
8
|
+
|
|
9
|
+
# Bump when full crawls start collecting new fields: repos fully crawled by an older version are re-crawled in full.
|
|
10
|
+
CRAWL_VERSION = 2
|
|
11
|
+
|
|
12
|
+
SCHEMA = """
|
|
13
|
+
CREATE TABLE IF NOT EXISTS repos (
|
|
14
|
+
repo_id INTEGER PRIMARY KEY, name TEXT NOT NULL, stars INTEGER, pushed_at TEXT, priority INTEGER DEFAULT 0,
|
|
15
|
+
status TEXT NOT NULL DEFAULT 'queued', attempts INTEGER DEFAULT 0, run_id TEXT, claimed_at TEXT,
|
|
16
|
+
last_done_at TEXT, next_due TEXT, last_points INTEGER, last_prs INTEGER, last_issues INTEGER, error TEXT,
|
|
17
|
+
last_full_at TEXT, crawl_version INTEGER DEFAULT 1);
|
|
18
|
+
CREATE INDEX IF NOT EXISTS repos_claim ON repos(status, priority DESC, stars DESC);
|
|
19
|
+
CREATE TABLE IF NOT EXISTS runs (run_id TEXT PRIMARY KEY, kind TEXT, started_at TEXT, finished_at TEXT,
|
|
20
|
+
points INTEGER, repos INTEGER, reason TEXT);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS issue_state (repo_id INTEGER, number INTEGER, observed_at TEXT, state TEXT,
|
|
22
|
+
assignees INTEGER, linked_open_prs INTEGER, linked_merged_prs INTEGER, comments INTEGER, labels TEXT,
|
|
23
|
+
updated_at TEXT, PRIMARY KEY (repo_id, number));
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CrawlState:
|
|
28
|
+
def __init__(self, path: Path, now: Callable[[], datetime] = lambda: datetime.now(timezone.utc)):
|
|
29
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
self.now = now
|
|
31
|
+
self.lock = threading.Lock()
|
|
32
|
+
self.db = sqlite3.connect(path, check_same_thread=False)
|
|
33
|
+
self.db.row_factory = sqlite3.Row
|
|
34
|
+
with self.db:
|
|
35
|
+
self.db.executescript(SCHEMA)
|
|
36
|
+
# State files saved before refreshes became incremental: their crawls were all full ones.
|
|
37
|
+
columns = {r[1] for r in self.db.execute("PRAGMA table_info(repos)")}
|
|
38
|
+
if "last_full_at" not in columns:
|
|
39
|
+
self.db.execute("ALTER TABLE repos ADD COLUMN last_full_at TEXT")
|
|
40
|
+
self.db.execute("UPDATE repos SET last_full_at = last_done_at WHERE last_done_at IS NOT NULL")
|
|
41
|
+
if "crawl_version" not in columns:
|
|
42
|
+
self.db.execute("ALTER TABLE repos ADD COLUMN crawl_version INTEGER DEFAULT 1")
|
|
43
|
+
|
|
44
|
+
def _ts(self) -> str:
|
|
45
|
+
return self.now().isoformat(timespec="seconds")
|
|
46
|
+
|
|
47
|
+
def upsert_repos(self, rows: list[dict]) -> int:
|
|
48
|
+
# New repos are queued; known repos get fresher metadata and the latest seed's priority.
|
|
49
|
+
added = 0
|
|
50
|
+
with self.lock, self.db:
|
|
51
|
+
for r in rows:
|
|
52
|
+
known = self.db.execute("SELECT 1 FROM repos WHERE repo_id = ?", (r["repo_id"],)).fetchone()
|
|
53
|
+
if known:
|
|
54
|
+
self.db.execute("UPDATE repos SET name = ?, stars = ?, pushed_at = ?, priority = ? "
|
|
55
|
+
"WHERE repo_id = ?",
|
|
56
|
+
(r["name"], r.get("stars"), r.get("pushed_at"), r.get("priority", 0), r["repo_id"]))
|
|
57
|
+
else:
|
|
58
|
+
self.db.execute("INSERT INTO repos (repo_id, name, stars, pushed_at, priority) VALUES (?, ?, ?, ?, ?)",
|
|
59
|
+
(r["repo_id"], r["name"], r.get("stars"), r.get("pushed_at"), r.get("priority", 0)))
|
|
60
|
+
added += 1
|
|
61
|
+
return added
|
|
62
|
+
|
|
63
|
+
def claim(self, n: int, run_id: str) -> list[dict]:
|
|
64
|
+
now = self._ts()
|
|
65
|
+
with self.lock, self.db:
|
|
66
|
+
rows = self.db.execute(
|
|
67
|
+
"SELECT repo_id, name, stars, priority, last_done_at, last_full_at, crawl_version FROM repos "
|
|
68
|
+
"WHERE status = 'queued' OR (status = 'done' AND ((next_due IS NOT NULL AND next_due <= ?) "
|
|
69
|
+
"OR crawl_version < ?)) ORDER BY priority DESC, stars DESC LIMIT ?",
|
|
70
|
+
(now, CRAWL_VERSION, n)).fetchall()
|
|
71
|
+
for r in rows:
|
|
72
|
+
self.db.execute("UPDATE repos SET status = 'running', run_id = ?, claimed_at = ?, "
|
|
73
|
+
"attempts = attempts + 1 WHERE repo_id = ?", (run_id, now, r["repo_id"]))
|
|
74
|
+
return [dict(r) for r in rows]
|
|
75
|
+
|
|
76
|
+
def done(self, repo_id: int, run_id: str, points: int, prs: int, issues: int, refresh_days: int = 7,
|
|
77
|
+
full: bool = True) -> None:
|
|
78
|
+
due = (self.now() + timedelta(days=refresh_days)).isoformat(timespec="seconds")
|
|
79
|
+
now = self._ts()
|
|
80
|
+
with self.lock, self.db:
|
|
81
|
+
self.db.execute("UPDATE repos SET status = 'done', last_done_at = ?, next_due = ?, last_points = ?, "
|
|
82
|
+
"last_prs = ?, last_issues = ?, error = NULL, attempts = 0, "
|
|
83
|
+
"last_full_at = CASE WHEN ? THEN ? ELSE last_full_at END, "
|
|
84
|
+
"crawl_version = CASE WHEN ? THEN ? ELSE crawl_version END WHERE repo_id = ?",
|
|
85
|
+
(now, due, points, prs, issues, full, now, full, CRAWL_VERSION, repo_id))
|
|
86
|
+
|
|
87
|
+
def fail(self, repo_id: int, run_id: str, error: str) -> None:
|
|
88
|
+
with self.lock, self.db:
|
|
89
|
+
self.db.execute("UPDATE repos SET status = 'failed', error = ? WHERE repo_id = ?", (error[:300], repo_id))
|
|
90
|
+
|
|
91
|
+
def gone(self, repo_id: int, run_id: str, reason: str) -> None:
|
|
92
|
+
with self.lock, self.db:
|
|
93
|
+
self.db.execute("UPDATE repos SET status = 'gone', error = ? WHERE repo_id = ?", (reason[:300], repo_id))
|
|
94
|
+
|
|
95
|
+
def release(self, repo_id: int) -> None:
|
|
96
|
+
# Back to the queue without counting an attempt (the run stopped, the repo did not fail).
|
|
97
|
+
with self.lock, self.db:
|
|
98
|
+
self.db.execute("UPDATE repos SET status = 'queued', attempts = MAX(attempts - 1, 0) WHERE repo_id = ?",
|
|
99
|
+
(repo_id,))
|
|
100
|
+
|
|
101
|
+
def requeue_stale(self, older_than_minutes: int = 120) -> int:
|
|
102
|
+
cutoff = (self.now() - timedelta(minutes=older_than_minutes)).isoformat(timespec="seconds")
|
|
103
|
+
with self.lock, self.db:
|
|
104
|
+
cur = self.db.execute("UPDATE repos SET status = 'queued' WHERE status = 'running' AND claimed_at < ?",
|
|
105
|
+
(cutoff,))
|
|
106
|
+
return cur.rowcount
|
|
107
|
+
|
|
108
|
+
def requeue_failed(self, max_attempts: int = 3) -> int:
|
|
109
|
+
with self.lock, self.db:
|
|
110
|
+
cur = self.db.execute("UPDATE repos SET status = 'queued' WHERE status = 'failed' AND attempts < ?",
|
|
111
|
+
(max_attempts,))
|
|
112
|
+
return cur.rowcount
|
|
113
|
+
|
|
114
|
+
def start_run(self, run_id: str, kind: str) -> None:
|
|
115
|
+
with self.lock, self.db:
|
|
116
|
+
self.db.execute("INSERT OR IGNORE INTO runs (run_id, kind, started_at) VALUES (?, ?, ?)",
|
|
117
|
+
(run_id, kind, self._ts()))
|
|
118
|
+
|
|
119
|
+
def finish_run(self, run_id: str, points: int, repos: int, reason: str) -> None:
|
|
120
|
+
with self.lock, self.db:
|
|
121
|
+
self.db.execute("UPDATE runs SET finished_at = ?, points = ?, repos = ?, reason = ? WHERE run_id = ?",
|
|
122
|
+
(self._ts(), points, repos, reason, run_id))
|
|
123
|
+
|
|
124
|
+
def due_count(self) -> int:
|
|
125
|
+
# Same rule as claim(): queued, due for a refresh, or crawled by an older crawler.
|
|
126
|
+
with self.lock:
|
|
127
|
+
return self.db.execute(
|
|
128
|
+
"SELECT COUNT(*) FROM repos WHERE status = 'queued' OR (status = 'done' AND "
|
|
129
|
+
"((next_due IS NOT NULL AND next_due <= ?) OR crawl_version < ?))",
|
|
130
|
+
(self._ts(), CRAWL_VERSION)).fetchone()[0]
|
|
131
|
+
|
|
132
|
+
def counts(self) -> dict:
|
|
133
|
+
with self.lock:
|
|
134
|
+
return dict(self.db.execute("SELECT status, COUNT(*) FROM repos GROUP BY status").fetchall())
|
|
135
|
+
|
|
136
|
+
def log_issue_state(self, rows: list[dict], observed_at: str) -> None:
|
|
137
|
+
# Only the latest state per tracked issue lives here; the history is in the Parquet batches.
|
|
138
|
+
with self.lock, self.db:
|
|
139
|
+
self.db.executemany(
|
|
140
|
+
"INSERT OR REPLACE INTO issue_state (repo_id, number, observed_at, state, assignees, linked_open_prs, "
|
|
141
|
+
"linked_merged_prs, comments, labels, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
142
|
+
[(r["repo_id"], r["number"], observed_at, r["state"], r["assignees"], r["linked_open_prs"],
|
|
143
|
+
r["linked_merged_prs"], r["comments"], r["labels"], r.get("updated_at")) for r in rows])
|
|
144
|
+
|
|
145
|
+
def latest_issue_state(self, repo_id: int) -> list[dict]:
|
|
146
|
+
with self.lock:
|
|
147
|
+
rows = self.db.execute("SELECT * FROM issue_state WHERE repo_id = ? ORDER BY number", (repo_id,)).fetchall()
|
|
148
|
+
return [dict(r) for r in rows]
|
|
149
|
+
|
|
150
|
+
def open_issue_numbers(self, repo_id: int) -> list[int]:
|
|
151
|
+
with self.lock:
|
|
152
|
+
rows = self.db.execute("SELECT number FROM issue_state WHERE repo_id = ? AND state = 'OPEN' ORDER BY number",
|
|
153
|
+
(repo_id,)).fetchall()
|
|
154
|
+
return [r[0] for r in rows]
|
|
155
|
+
|
|
156
|
+
def has_hot_tracked(self, repo_id: int) -> bool:
|
|
157
|
+
# A refresh only sees changed issues, so hotness also comes from the beginner issues already tracked.
|
|
158
|
+
with self.lock:
|
|
159
|
+
rows = self.db.execute("SELECT labels FROM issue_state WHERE repo_id = ? AND state = 'OPEN'",
|
|
160
|
+
(repo_id,)).fetchall()
|
|
161
|
+
return any(has_opportunity((r[0] or "").split("|")) for r in rows)
|
|
162
|
+
|
|
163
|
+
def forget_issues(self, repo_id: int, numbers: list[int]) -> None:
|
|
164
|
+
with self.lock, self.db:
|
|
165
|
+
self.db.executemany("DELETE FROM issue_state WHERE repo_id = ? AND number = ?",
|
|
166
|
+
[(repo_id, n) for n in numbers])
|
|
167
|
+
|
|
168
|
+
def close(self) -> None:
|
|
169
|
+
self.db.close()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pyarrow as pa
|
|
6
|
+
import pyarrow.parquet as pq
|
|
7
|
+
|
|
8
|
+
TS = pa.timestamp("us", tz="UTC")
|
|
9
|
+
|
|
10
|
+
# Fixed schemas so every batch file of a table can be read together later.
|
|
11
|
+
SCHEMAS = {
|
|
12
|
+
"repo_meta": pa.schema([
|
|
13
|
+
("repo_id", pa.int64()), ("name", pa.string()), ("stars", pa.int32()), ("forks", pa.int32()),
|
|
14
|
+
("pushed_at", TS), ("language", pa.string()), ("archived", pa.bool_()), ("has_issues", pa.bool_()),
|
|
15
|
+
("has_discussions", pa.bool_()), ("has_projects", pa.bool_()), ("license", pa.string()),
|
|
16
|
+
("default_branch", pa.string()), ("contributing_sha", pa.string()), ("ai_policy_sha", pa.string()),
|
|
17
|
+
("pr_template_sha", pa.string()), ("readme_excerpt", pa.string()), ("last_release_at", TS),
|
|
18
|
+
("releases_12m", pa.int32()), ("open_prs", pa.int32()), ("open_issues", pa.int32()),
|
|
19
|
+
("contributing_excerpt", pa.string()), ("label_names", pa.list_(pa.string())),
|
|
20
|
+
("owner_logins", pa.list_(pa.string())),
|
|
21
|
+
("run_id", pa.string()), ("fetched_at", TS)]),
|
|
22
|
+
"prs": pa.schema([
|
|
23
|
+
("repo_id", pa.int64()), ("number", pa.int32()), ("created_at", TS), ("merged_at", TS), ("closed_at", TS),
|
|
24
|
+
("state", pa.string()), ("author_login", pa.string()), ("author_type", pa.string()),
|
|
25
|
+
("author_association", pa.string()), ("merged_by", pa.string()), ("head_ref", pa.string()),
|
|
26
|
+
("additions", pa.int32()), ("deletions", pa.int32()), ("changed_files", pa.int32()),
|
|
27
|
+
("first_review_at", TS), ("first_review_by", pa.string()), ("first_review_association", pa.string()),
|
|
28
|
+
("first_human_review_at", TS), ("first_human_review_by", pa.string()),
|
|
29
|
+
("first_human_review_association", pa.string()),
|
|
30
|
+
("closing_issue_numbers", pa.list_(pa.int32())), ("ai_tag", pa.string()),
|
|
31
|
+
("title", pa.string()), ("ci_state", pa.string()), ("commit_count", pa.int32()), ("run_id", pa.string()), ("fetched_at", TS)]),
|
|
32
|
+
"issues": pa.schema([
|
|
33
|
+
("repo_id", pa.int64()), ("number", pa.int32()), ("title", pa.string()), ("body_excerpt", pa.string()),
|
|
34
|
+
("state", pa.string()), ("state_reason", pa.string()), ("created_at", TS), ("updated_at", TS),
|
|
35
|
+
("closed_at", TS), ("author_login", pa.string()), ("author_association", pa.string()),
|
|
36
|
+
("labels", pa.list_(pa.string())),
|
|
37
|
+
("assignees", pa.int32()), ("linked_open_prs", pa.int32()), ("linked_merged_prs", pa.int32()),
|
|
38
|
+
("comments_total", pa.int32()), ("linked_closed_prs", pa.int32()), ("fix_pr", pa.int32()),
|
|
39
|
+
("fix_pr_state", pa.string()), ("fix_author", pa.string()), ("fix_association", pa.string()), ("run_id", pa.string()), ("fetched_at", TS)]),
|
|
40
|
+
"comments": pa.schema([
|
|
41
|
+
("repo_id", pa.int64()), ("number", pa.int32()), ("comment_id", pa.int64()), ("created_at", TS),
|
|
42
|
+
("author_login", pa.string()), ("author_type", pa.string()), ("author_association", pa.string()),
|
|
43
|
+
("body_excerpt", pa.string()), ("run_id", pa.string()), ("fetched_at", TS)]),
|
|
44
|
+
"issue_events": pa.schema([
|
|
45
|
+
("repo_id", pa.int64()), ("number", pa.int32()), ("event", pa.string()), ("detail", pa.string()),
|
|
46
|
+
("observed_at", pa.string()), ("run_id", pa.string()), ("fetched_at", TS)]),
|
|
47
|
+
"closed_issues": pa.schema([
|
|
48
|
+
("repo_id", pa.int64()), ("number", pa.int32()), ("created_at", TS), ("closed_at", TS),
|
|
49
|
+
("state_reason", pa.string()), ("author_login", pa.string()), ("author_association", pa.string()),
|
|
50
|
+
("labels", pa.list_(pa.string())), ("fix_pr", pa.int32()), ("fix_pr_state", pa.string()),
|
|
51
|
+
("fix_author", pa.string()), ("fix_association", pa.string()), ("run_id", pa.string()),
|
|
52
|
+
("fetched_at", TS)]),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BatchWriter:
|
|
57
|
+
def __init__(self, data_dir: Path, run_id: str, now=lambda: datetime.now(timezone.utc)):
|
|
58
|
+
self.base = data_dir / "crawl"
|
|
59
|
+
self.run_id = run_id
|
|
60
|
+
self.now = now
|
|
61
|
+
self.rows: dict[str, list[dict]] = {}
|
|
62
|
+
self.seq = 0
|
|
63
|
+
|
|
64
|
+
def add(self, table: str, rows: list[dict]) -> None:
|
|
65
|
+
if table not in SCHEMAS:
|
|
66
|
+
raise KeyError(f"unknown table {table}")
|
|
67
|
+
if rows:
|
|
68
|
+
# Every row says when it was fetched, so later layers can keep the newest copy.
|
|
69
|
+
stamp = self.now()
|
|
70
|
+
self.rows.setdefault(table, []).extend({**r, "run_id": self.run_id, "fetched_at": stamp} for r in rows)
|
|
71
|
+
|
|
72
|
+
def flush(self) -> dict[str, Path]:
|
|
73
|
+
# Each flush is a new file per table; a file is complete or absent (written as .part, then renamed).
|
|
74
|
+
out = {}
|
|
75
|
+
for table, rows in self.rows.items():
|
|
76
|
+
schema = SCHEMAS[table]
|
|
77
|
+
path = self.base / table / f"run={self.run_id}-{self.seq:03d}.parquet"
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
part = path.with_name(path.name + ".part")
|
|
80
|
+
cols = {f.name: [r.get(f.name) for r in rows] for f in schema}
|
|
81
|
+
pq.write_table(pa.table(cols, schema=schema), part, compression="zstd")
|
|
82
|
+
os.replace(part, path)
|
|
83
|
+
out[table] = path
|
|
84
|
+
if out:
|
|
85
|
+
self.seq += 1
|
|
86
|
+
self.rows = {}
|
|
87
|
+
return out
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
from mergescout.config import load_config
|
|
6
|
+
from mergescout.discover.sweep import SweepState, initial_slices, run_sweep
|
|
7
|
+
from mergescout.github.graphql import GitHubGraphQL
|
|
8
|
+
from mergescout.github.identities import identities_from_env, slots
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> int:
|
|
12
|
+
parser = argparse.ArgumentParser(prog="python -m mergescout.discover", description="List public repos at scale.")
|
|
13
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
14
|
+
sw = sub.add_parser("sweep", help="List every repo above a star count (safe to stop and re-run)")
|
|
15
|
+
sw.add_argument("--min-stars", type=int, default=10)
|
|
16
|
+
sw.add_argument("--workers", type=int, default=4)
|
|
17
|
+
sw.add_argument("--active-days", type=int, default=None, help="Only repos pushed in this many days")
|
|
18
|
+
sw.add_argument("--max-wait", type=float, default=None,
|
|
19
|
+
help="Pause instead of waiting longer than this many seconds for the rate limit")
|
|
20
|
+
sub.add_parser("status", help="Show sweep progress")
|
|
21
|
+
args = parser.parse_args(argv)
|
|
22
|
+
|
|
23
|
+
cfg = load_config()
|
|
24
|
+
db_path = cfg.data_dir / "discover" / "sweep.sqlite"
|
|
25
|
+
if args.cmd == "status":
|
|
26
|
+
state = SweepState(db_path)
|
|
27
|
+
rows, cost = state.totals()
|
|
28
|
+
print(f"repos={rows:,} points={cost:,} slices={state.counts()}")
|
|
29
|
+
return 0
|
|
30
|
+
token = cfg.require_token()
|
|
31
|
+
state = SweepState(db_path, resume=True)
|
|
32
|
+
saved = state.setting("min_stars")
|
|
33
|
+
if saved is not None and int(saved) != args.min_stars:
|
|
34
|
+
print(f"This sweep was started with --min-stars {saved}; delete {db_path} to start over.", file=sys.stderr)
|
|
35
|
+
return 2
|
|
36
|
+
state.setting("min_stars", str(args.min_stars))
|
|
37
|
+
now = datetime.now(timezone.utc).replace(microsecond=0)
|
|
38
|
+
# The activity cutoff is fixed when a sweep starts, so a resumed sweep keeps the same slices.
|
|
39
|
+
if args.active_days and state.setting("pushed_after") is None and state.active() == 0 and not state.counts():
|
|
40
|
+
state.setting("pushed_after", (now - timedelta(days=args.active_days)).strftime("%Y-%m-%dT%H:%M:%SZ"))
|
|
41
|
+
pushed = state.setting("pushed_after")
|
|
42
|
+
state.seed(initial_slices(args.min_stars, datetime(2007, 1, 1, tzinfo=timezone.utc), now,
|
|
43
|
+
datetime.fromisoformat(pushed.replace("Z", "+00:00")) if pushed else None))
|
|
44
|
+
# Every identity (personal token, App, workflow token) gets workers in proportion to its hourly points.
|
|
45
|
+
identities = identities_from_env(token, args.max_wait, client=GitHubGraphQL)
|
|
46
|
+
factories = [i.make_gql for i in identities for _ in range(slots(args.workers, i.share))]
|
|
47
|
+
paused = run_sweep(factories, state, cfg.data_dir / "discover", log=lambda msg: print(msg, flush=True))
|
|
48
|
+
rows, cost = state.totals()
|
|
49
|
+
counts = state.counts()
|
|
50
|
+
for i in identities:
|
|
51
|
+
print(f"github {i.name}: {i.gate.summary()}", flush=True)
|
|
52
|
+
word = "paused (rate limit; run again to resume)" if paused else "finished"
|
|
53
|
+
print(f"{word}: repos={rows:,} points={cost:,} slices={counts}", file=sys.stderr)
|
|
54
|
+
return 1 if counts.get("failed") else 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
raise SystemExit(main())
|