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,34 @@
|
|
|
1
|
+
# The file every user downloads; bump the major version only for changes old tools cannot read.
|
|
2
|
+
SCHEMA_VERSION = "1.1"
|
|
3
|
+
SCHEMA_MAJOR = 1
|
|
4
|
+
|
|
5
|
+
DDL = """
|
|
6
|
+
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
7
|
+
CREATE TABLE repos (
|
|
8
|
+
repo_id INTEGER PRIMARY KEY, name TEXT NOT NULL, stars INTEGER, language TEXT COLLATE NOCASE, pushed_at TEXT,
|
|
9
|
+
archived INTEGER, license TEXT, has_discussions INTEGER, open_issues INTEGER, open_prs INTEGER, insiders INTEGER,
|
|
10
|
+
outsider_prs INTEGER, merged INTEGER, closed INTEGER, merge_rate REAL, merge_low REAL, merge_high REAL,
|
|
11
|
+
merge_enough INTEGER, newcomer_merged INTEGER, newcomer_decided INTEGER, newcomer_rate REAL, newcomer_low REAL,
|
|
12
|
+
newcomer_high REAL, newcomer_enough INTEGER, reviewed INTEGER, median_review_hours REAL, review_enough INTEGER,
|
|
13
|
+
window_days INTEGER, window_complete INTEGER, ai_prs INTEGER, bot_prs INTEGER, insider_prs INTEGER,
|
|
14
|
+
blocking_walls INTEGER NOT NULL, walls_json TEXT NOT NULL, labels_json TEXT NOT NULL, notes TEXT NOT NULL,
|
|
15
|
+
contributing INTEGER,
|
|
16
|
+
fetched_at TEXT);
|
|
17
|
+
CREATE UNIQUE INDEX repos_name ON repos (name COLLATE NOCASE);
|
|
18
|
+
CREATE TABLE issues (
|
|
19
|
+
repo_id INTEGER NOT NULL, number INTEGER NOT NULL, title TEXT, body TEXT, labels TEXT NOT NULL,
|
|
20
|
+
language TEXT COLLATE NOCASE, created_at TEXT, updated_at TEXT, available INTEGER NOT NULL,
|
|
21
|
+
opportunity INTEGER NOT NULL, blocked_by TEXT, assignees INTEGER, linked_open_prs INTEGER, dead_ends INTEGER,
|
|
22
|
+
claimers INTEGER, last_claim_at TEXT, interest_30d INTEGER, score REAL NOT NULL, rank_score REAL NOT NULL,
|
|
23
|
+
p_avail REAL, p_merge REAL, p_reply REAL, p_fixed REAL, p_quality REAL, mult REAL, fetched_at TEXT,
|
|
24
|
+
beginner INTEGER NOT NULL DEFAULT 0, kind TEXT,
|
|
25
|
+
UNIQUE (repo_id, number));
|
|
26
|
+
CREATE INDEX issues_rank ON issues (available, language, rank_score DESC);
|
|
27
|
+
CREATE INDEX issues_rank_all ON issues (available, rank_score DESC);
|
|
28
|
+
CREATE TABLE outsider_prs (repo_id INTEGER NOT NULL, number INTEGER NOT NULL, merged_at TEXT, lines INTEGER,
|
|
29
|
+
files INTEGER, days_to_merge REAL);
|
|
30
|
+
CREATE INDEX outsider_prs_repo ON outsider_prs (repo_id);
|
|
31
|
+
-- detail=column: single-word search only, about half the index size.
|
|
32
|
+
CREATE VIRTUAL TABLE issues_fts USING fts5(title, labels, body, content='issues', content_rowid='rowid',
|
|
33
|
+
tokenize='porter unicode61', detail=column);
|
|
34
|
+
"""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class Weights:
|
|
6
|
+
# Architecture §6 until calibrated odds exist; every number here is config, not a law.
|
|
7
|
+
availability: float = 0.30
|
|
8
|
+
outsider_merge: float = 0.30
|
|
9
|
+
reply: float = 0.15
|
|
10
|
+
fixed_before: float = 0.10
|
|
11
|
+
issue_quality: float = 0.15
|
|
12
|
+
prior_merge: float = 0.2
|
|
13
|
+
prior_reply: float = 0.3
|
|
14
|
+
prior_fixed: float = 0.3
|
|
15
|
+
reply_half_hours: float = 48.0
|
|
16
|
+
dead_end: float = 0.8
|
|
17
|
+
dead_end_floor: float = 0.5
|
|
18
|
+
crowded_interest: int = 3
|
|
19
|
+
crowded: float = 0.7
|
|
20
|
+
blocking_wall: float = 0.85
|
|
21
|
+
freshness_half_days: float = 30.0
|
|
22
|
+
discussion: float = 0.3
|
|
23
|
+
untriaged: float = 0.6
|
|
24
|
+
old_days: int = 365
|
|
25
|
+
old: float = 0.75
|
|
26
|
+
very_old_days: int = 730
|
|
27
|
+
very_old: float = 0.5
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
WEIGHTS = Weights()
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mergescout.config import load_config
|
|
8
|
+
from mergescout.github.releases import Releases
|
|
9
|
+
from mergescout.store.sync import pull, push
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(prog="python -m mergescout.store",
|
|
14
|
+
description="Keep crawl data and state in this repo's GitHub releases.")
|
|
15
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
16
|
+
ps = sub.add_parser("push", help="Upload new crawl batches and a state backup, then clear uploaded batches")
|
|
17
|
+
ps.add_argument("--with-discover", action="store_true", help="Also back up the sweep results")
|
|
18
|
+
sub.add_parser("pull", help="Restore state from the releases when the local copy is missing")
|
|
19
|
+
put = sub.add_parser("put", help="Upload one file to a release tag")
|
|
20
|
+
put.add_argument("path")
|
|
21
|
+
put.add_argument("--tag", required=True)
|
|
22
|
+
# MS_STORE_REPO lets a crawler repo keep its data in another (private) repo; otherwise the running repo.
|
|
23
|
+
parser.add_argument("--repo", default=os.environ.get("MS_STORE_REPO") or os.environ.get("GITHUB_REPOSITORY"),
|
|
24
|
+
help="owner/name of the repo whose releases hold the data")
|
|
25
|
+
args = parser.parse_args(argv)
|
|
26
|
+
|
|
27
|
+
token = os.environ.get("MS_RELEASE_TOKEN")
|
|
28
|
+
if not token or not args.repo:
|
|
29
|
+
print("Set MS_RELEASE_TOKEN (a token that can write releases) and GITHUB_REPOSITORY or --repo.",
|
|
30
|
+
file=sys.stderr)
|
|
31
|
+
return 1
|
|
32
|
+
cfg = load_config()
|
|
33
|
+
releases = Releases(token, args.repo)
|
|
34
|
+
log = lambda msg: print(msg, flush=True) # noqa: E731
|
|
35
|
+
if args.cmd == "push":
|
|
36
|
+
push(cfg.data_dir, releases, datetime.now(timezone.utc), with_discover=args.with_discover, log=log)
|
|
37
|
+
elif args.cmd == "put":
|
|
38
|
+
releases.upload(args.tag, Path(args.path))
|
|
39
|
+
log(f"uploaded {Path(args.path).name} to {args.tag}")
|
|
40
|
+
else:
|
|
41
|
+
pull(cfg.data_dir, releases, log=log)
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
raise SystemExit(main())
|
mergescout/store/sync.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import gzip
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import sqlite3
|
|
6
|
+
import tarfile
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
STATE_TAG = "crawl-state"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def data_tag(now: datetime) -> str:
|
|
14
|
+
# One data release per ISO week: hourly runs stay well under GitHub's 1,000 files per release.
|
|
15
|
+
return f"crawl-{now:%G-W%V}"
|
|
16
|
+
RUN_FILE = re.compile(r"^run=(?P<run>.+)-(?P<seq>\d{3})\.parquet$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pending_runs(data_dir: Path) -> dict[str, list[Path]]:
|
|
20
|
+
runs: dict[str, list[Path]] = {}
|
|
21
|
+
for p in sorted((data_dir / "crawl").glob("*/run=*.parquet")):
|
|
22
|
+
m = RUN_FILE.match(p.name)
|
|
23
|
+
if m:
|
|
24
|
+
runs.setdefault(m["run"], []).append(p)
|
|
25
|
+
return runs
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _tar(paths: list[Path], base: Path, out: Path) -> Path:
|
|
29
|
+
with tarfile.open(out, "w") as tar:
|
|
30
|
+
for p in paths:
|
|
31
|
+
tar.add(p, arcname=p.relative_to(base).as_posix())
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def safe_extract(archive: Path, dest: Path) -> None:
|
|
36
|
+
# Only plain files and folders that land inside dest; anything else means a broken or hostile archive.
|
|
37
|
+
root = dest.resolve()
|
|
38
|
+
with tarfile.open(archive) as tar:
|
|
39
|
+
members = tar.getmembers()
|
|
40
|
+
for m in members:
|
|
41
|
+
target = (dest / m.name).resolve()
|
|
42
|
+
if not (m.isfile() or m.isdir()) or (target != root and root not in target.parents):
|
|
43
|
+
raise ValueError(f"refusing archive entry {m.name!r}")
|
|
44
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
for m in members:
|
|
46
|
+
target = dest / m.name
|
|
47
|
+
if m.isdir():
|
|
48
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
continue
|
|
50
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
with tar.extractfile(m) as src, target.open("wb") as out:
|
|
52
|
+
shutil.copyfileobj(src, out)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _repo_count(state_file: Path) -> int:
|
|
56
|
+
db = sqlite3.connect(state_file)
|
|
57
|
+
try:
|
|
58
|
+
return db.execute("SELECT count(*) FROM repos").fetchone()[0]
|
|
59
|
+
except sqlite3.Error:
|
|
60
|
+
return 0
|
|
61
|
+
finally:
|
|
62
|
+
db.close()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def push(data_dir: Path, releases, now: datetime, with_discover: bool = False, log=print) -> dict:
|
|
66
|
+
# Upload first, delete after: a failed upload leaves the files for the next run to retry.
|
|
67
|
+
outbox = data_dir / "outbox"
|
|
68
|
+
outbox.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
out = {"runs": 0, "state": False, "discover": False}
|
|
70
|
+
for run, files in pending_runs(data_dir).items():
|
|
71
|
+
bundle = _tar(files, data_dir, outbox / f"crawl-{run}.tar")
|
|
72
|
+
releases.upload(data_tag(now), bundle)
|
|
73
|
+
for p in files:
|
|
74
|
+
p.unlink()
|
|
75
|
+
bundle.unlink()
|
|
76
|
+
out["runs"] += 1
|
|
77
|
+
state_file = data_dir / "crawl.sqlite"
|
|
78
|
+
if state_file.exists() and _repo_count(state_file) == 0:
|
|
79
|
+
# An empty queue means the state was not restored; never let it replace the good backup.
|
|
80
|
+
log("not backing up an empty crawl state")
|
|
81
|
+
elif state_file.exists():
|
|
82
|
+
snap, packed = outbox / "crawl.sqlite", outbox / "crawl.sqlite.gz"
|
|
83
|
+
src, dst = sqlite3.connect(state_file), sqlite3.connect(snap)
|
|
84
|
+
with dst:
|
|
85
|
+
src.backup(dst)
|
|
86
|
+
src.close()
|
|
87
|
+
dst.close()
|
|
88
|
+
with snap.open("rb") as f, gzip.open(packed, "wb") as g:
|
|
89
|
+
shutil.copyfileobj(f, g)
|
|
90
|
+
releases.upload(STATE_TAG, packed)
|
|
91
|
+
snap.unlink()
|
|
92
|
+
packed.unlink()
|
|
93
|
+
out["state"] = True
|
|
94
|
+
discover = data_dir / "discover"
|
|
95
|
+
if with_discover and discover.exists():
|
|
96
|
+
files = [p for p in sorted(discover.rglob("*")) if p.is_file()]
|
|
97
|
+
bundle = _tar(files, data_dir, outbox / "discover.tar")
|
|
98
|
+
releases.upload(STATE_TAG, bundle)
|
|
99
|
+
bundle.unlink()
|
|
100
|
+
out["discover"] = True
|
|
101
|
+
log(f"pushed: {out}")
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def pull(data_dir: Path, releases, log=print) -> dict:
|
|
106
|
+
# Only used when the Actions cache is gone; local files always win.
|
|
107
|
+
outbox = data_dir / "outbox"
|
|
108
|
+
out = {"state": False, "discover": False}
|
|
109
|
+
state_file = data_dir / "crawl.sqlite"
|
|
110
|
+
if not state_file.exists():
|
|
111
|
+
packed = outbox / "crawl.sqlite.gz"
|
|
112
|
+
if releases.download(STATE_TAG, "crawl.sqlite.gz", packed):
|
|
113
|
+
part = state_file.with_name(state_file.name + ".part")
|
|
114
|
+
with gzip.open(packed, "rb") as g, part.open("wb") as f:
|
|
115
|
+
shutil.copyfileobj(g, f)
|
|
116
|
+
os.replace(part, state_file)
|
|
117
|
+
packed.unlink()
|
|
118
|
+
out["state"] = True
|
|
119
|
+
if not (data_dir / "discover").exists():
|
|
120
|
+
bundle = outbox / "discover.tar"
|
|
121
|
+
if releases.download(STATE_TAG, "discover.tar", bundle):
|
|
122
|
+
safe_extract(bundle, data_dir)
|
|
123
|
+
bundle.unlink()
|
|
124
|
+
out["discover"] = True
|
|
125
|
+
log(f"pulled: {out}")
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def fetch_bronze(releases, data_dir: Path, log=print) -> int:
|
|
130
|
+
# Each crawl bundle (weekly and older monthly data releases) is downloaded and unpacked once.
|
|
131
|
+
bronze, got = data_dir / "bronze", 0
|
|
132
|
+
for release in releases.list_releases():
|
|
133
|
+
if not release["tag_name"].startswith("crawl-") or release["tag_name"] == STATE_TAG:
|
|
134
|
+
continue
|
|
135
|
+
for asset in releases.assets(release):
|
|
136
|
+
name = asset["name"]
|
|
137
|
+
marker = bronze / "done" / name
|
|
138
|
+
if not (name.startswith("crawl-") and name.endswith(".tar")) or marker.exists():
|
|
139
|
+
continue
|
|
140
|
+
tar = bronze / "tars" / name
|
|
141
|
+
releases.download_asset(asset, tar)
|
|
142
|
+
safe_extract(tar, bronze)
|
|
143
|
+
tar.unlink()
|
|
144
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
marker.touch()
|
|
146
|
+
got += 1
|
|
147
|
+
log(f"bronze: {got} new bundles")
|
|
148
|
+
return got
|
mergescout/timeutil.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def parse_time(value: str | None) -> datetime | None:
|
|
5
|
+
# GitHub times look like 2026-10-01T15:00:01Z
|
|
6
|
+
if not value:
|
|
7
|
+
return None
|
|
8
|
+
try:
|
|
9
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
10
|
+
except ValueError:
|
|
11
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import statistics
|
|
3
|
+
|
|
4
|
+
from mergescout.labels import norm_label
|
|
5
|
+
from mergescout.tool.queries import rate_text, review_text
|
|
6
|
+
from mergescout.tool.refs import Ref
|
|
7
|
+
|
|
8
|
+
CHECKLIST = {
|
|
9
|
+
"cla": "Sign the project's CLA before opening the PR",
|
|
10
|
+
"dco": "Sign off every commit (git commit -s)",
|
|
11
|
+
"issue_first": "Comment on the issue and wait for a maintainer's OK before coding",
|
|
12
|
+
"ai_ban": "This project does not accept AI-written contributions; write it yourself",
|
|
13
|
+
"ai_policy": "Follow the project's AI rules and say in the PR how you used AI",
|
|
14
|
+
"no_outside_prs": "The project says it does not accept outside PRs right now",
|
|
15
|
+
"issues_disabled": "Issues are turned off; ask in the project's discussion channel first",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_action_pack(snap, ref: Ref, status: dict | None = None) -> dict:
|
|
20
|
+
issue, repo = snap.issue(ref.repo, ref.number), snap.repo(ref.repo)
|
|
21
|
+
if issue is None or repo is None:
|
|
22
|
+
raise ValueError(f"{ref.key} is not in today's snapshot; use check_issue for a live look.")
|
|
23
|
+
walls = json.loads(repo["walls_json"])
|
|
24
|
+
labels = {lb["label"]: lb for lb in json.loads(repo["labels_json"])}
|
|
25
|
+
examples = snap.examples(repo["repo_id"])
|
|
26
|
+
names = [norm_label(x) for x in json.loads(issue["labels"])]
|
|
27
|
+
fix = next((labels[lb] for lb in names if lb in labels and labels[lb].get("enough")), None)
|
|
28
|
+
if fix and fix.get("median_fix_lines") is not None:
|
|
29
|
+
level = f"typical fix for this label here: {fix['median_fix_lines']:.0f} lines in {fix['median_fix_files']:.0f} files"
|
|
30
|
+
elif examples:
|
|
31
|
+
level = f"recent accepted outsider PRs: median {statistics.median(e['lines'] or 0 for e in examples):.0f} lines"
|
|
32
|
+
else:
|
|
33
|
+
level = "not enough evidence to size this yet"
|
|
34
|
+
status = status or {"available": bool(issue["available"]),
|
|
35
|
+
"reasons": [b for b in (issue["blocked_by"] or "").split(", ") if b], "source": "snapshot",
|
|
36
|
+
"note": f"from the snapshot of {snap.built_at}; call check_issue for the live state"}
|
|
37
|
+
steps = []
|
|
38
|
+
if not status.get("available", True):
|
|
39
|
+
steps.append(f"Stop: this issue is not free right now ({', '.join(status.get('reasons') or [])}). "
|
|
40
|
+
"Pick another one or ask the person working on it.")
|
|
41
|
+
if any(w["wall"] == "issue_first" for w in walls):
|
|
42
|
+
steps.append("Post the draft comment and wait for a maintainer's reply before writing code.")
|
|
43
|
+
steps += ["Reproduce the problem locally and write down the exact steps.",
|
|
44
|
+
"Plan a test that fails before your change and passes after it.",
|
|
45
|
+
"Before opening the PR, explain the change in your own words in the description (no copy-paste).",
|
|
46
|
+
"Keep the PR small and link the issue with 'Fixes #%d'." % ref.number]
|
|
47
|
+
if any(w["wall"] in ("ai_policy", "ai_ban") for w in walls):
|
|
48
|
+
steps.append("Check the project's AI rules (see the checklist) before using an AI assistant.")
|
|
49
|
+
return {"issue": {"ref": ref.key, "url": f"https://github.com/{repo['name']}/issues/{ref.number}",
|
|
50
|
+
"title": issue["title"], "labels": json.loads(issue["labels"]), "asks": issue["body"]},
|
|
51
|
+
"status": status,
|
|
52
|
+
"repo": {"outsider_merge": rate_text(repo), "review": review_text(repo),
|
|
53
|
+
"how_prs_die": f"{repo['closed'] or 0} of {(repo['merged'] or 0) + (repo['closed'] or 0)} decided "
|
|
54
|
+
f"outsider PRs were closed without merging (last {repo['window_days']} days)",
|
|
55
|
+
"contributing": bool(repo["contributing"]), "discussions": bool(repo["has_discussions"])},
|
|
56
|
+
"accepted_examples": [{"url": f"https://github.com/{repo['name']}/pull/{e['number']}", "lines": e["lines"],
|
|
57
|
+
"files": e["files"], "days_to_merge": e["days_to_merge"]} for e in examples],
|
|
58
|
+
"policy_checklist": [f"{CHECKLIST[w['wall']]} (project text: \"{w['evidence']}\")" for w in walls
|
|
59
|
+
if w["wall"] in CHECKLIST],
|
|
60
|
+
"level": level, "next_steps": steps,
|
|
61
|
+
"draft_comment": ("Hi! I'd like to work on this. My plan: <one or two lines on the approach>. "
|
|
62
|
+
"Does that sound right, and could it be assigned to me?")}
|
mergescout/tool/live.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from mergescout.labels import has_opportunity, norm_label
|
|
8
|
+
from mergescout.signals.rules import (BOT_NAME_PATTERN, CLAIM_PATTERN, HUMAN_BOT_NAMES, KNOWN_BOTS, RULES,
|
|
9
|
+
TRIAGE_LABEL_PATTERN, UNKNOWN_LOGINS, WAIT_LABEL_PATTERN, WAIT_PATTERN)
|
|
10
|
+
|
|
11
|
+
API = "https://api.github.com"
|
|
12
|
+
INSIDER = {"OWNER", "MEMBER", "COLLABORATOR"}
|
|
13
|
+
FIELDS = """
|
|
14
|
+
fragment S on Issue {
|
|
15
|
+
title state stateReason updatedAt
|
|
16
|
+
assignees(first: 5) { totalCount }
|
|
17
|
+
labels(first: 20) { nodes { name } }
|
|
18
|
+
closedByPullRequestsReferences(first: 5, includeClosedPrs: false) { nodes { number state } }
|
|
19
|
+
timelineItems(last: 30, itemTypes: [CROSS_REFERENCED_EVENT]) { nodes { ... on CrossReferencedEvent {
|
|
20
|
+
source { __typename ... on PullRequest { number state author { __typename login } repository { nameWithOwner } } } } } }
|
|
21
|
+
comments(last: 10) { nodes { createdAt authorAssociation author { __typename login } bodyText } }
|
|
22
|
+
}"""
|
|
23
|
+
STATUS_QUERY = """
|
|
24
|
+
query($owner: String!, $name: String!, $number: Int!) {
|
|
25
|
+
rateLimit { cost remaining resetAt }
|
|
26
|
+
repository(owner: $owner, name: $name) { issue(number: $number) { ...S } }
|
|
27
|
+
}""" + FIELDS
|
|
28
|
+
# "Could you try the build from draft PR #1106?" points at work in progress the issue does not link.
|
|
29
|
+
PR_MENTION = re.compile(r"(?:\bpr\s*#|\bpull request\s*#|/pull/)(\d+)", re.I)
|
|
30
|
+
BATCH = 25
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LiveError(RuntimeError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _time(text: str | None) -> datetime | None:
|
|
38
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00")) if text else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _bot(login: str, typ: str | None) -> bool:
|
|
42
|
+
return (typ == "Bot" or login in KNOWN_BOTS or login in UNKNOWN_LOGINS or login.endswith("[bot]")
|
|
43
|
+
or bool(re.search(BOT_NAME_PATTERN, login) and not re.search(HUMAN_BOT_NAMES, login)))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def classify_comments(comments: list[dict], now: datetime) -> tuple[list, list, list]:
|
|
47
|
+
# Claims older than the claim window do not block, but a user should still see them before starting.
|
|
48
|
+
claims, waits, older = [], [], []
|
|
49
|
+
for c in comments:
|
|
50
|
+
login = (c.get("login") or "").lower()
|
|
51
|
+
if not login or _bot(login, c.get("type")):
|
|
52
|
+
continue
|
|
53
|
+
text, at = (c.get("body") or "").lower(), _time(c.get("created_at"))
|
|
54
|
+
item = {"by": c.get("login"), "at": c.get("created_at"), "text": (c.get("body") or "")[:160]}
|
|
55
|
+
if c.get("association") in INSIDER:
|
|
56
|
+
if re.search(WAIT_PATTERN, text):
|
|
57
|
+
waits.append(item)
|
|
58
|
+
elif re.search(CLAIM_PATTERN, text):
|
|
59
|
+
(claims if at and now - at <= timedelta(days=RULES.claim_days) else older).append(item)
|
|
60
|
+
return claims, waits, older
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _graphql_errors(body: dict) -> list[dict]:
|
|
64
|
+
errors = body.get("errors") or []
|
|
65
|
+
if any(e.get("type") == "RATE_LIMITED" or "rate limit" in (e.get("message") or "").lower() for e in errors):
|
|
66
|
+
raise LiveError("GitHub's rate limit is reached for this token; try again in a few minutes.")
|
|
67
|
+
return errors
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _from_graphql(issue: dict, repo: str) -> tuple[dict, list[dict]]:
|
|
71
|
+
comments = [{"login": (c.get("author") or {}).get("login"), "type": (c.get("author") or {}).get("__typename"),
|
|
72
|
+
"association": c.get("authorAssociation"), "body": c.get("bodyText"), "created_at": c.get("createdAt")}
|
|
73
|
+
for c in (issue.get("comments") or {}).get("nodes") or [] if c]
|
|
74
|
+
linked = [p["number"] for p in issue["closedByPullRequestsReferences"]["nodes"] if p and p["state"] == "OPEN"]
|
|
75
|
+
# Real data (2026-10-16): open PRs often say "related to #N" without the closing keyword GitHub links.
|
|
76
|
+
mentions = []
|
|
77
|
+
for ev in (issue.get("timelineItems") or {}).get("nodes") or []:
|
|
78
|
+
src = (ev or {}).get("source") or {}
|
|
79
|
+
author = src.get("author") or {}
|
|
80
|
+
if (src.get("__typename") == "PullRequest" and src.get("state") == "OPEN"
|
|
81
|
+
and (src.get("repository") or {}).get("nameWithOwner", "").lower() == repo.lower()
|
|
82
|
+
and src.get("number") not in linked and src.get("number") not in mentions
|
|
83
|
+
and not _bot((author.get("login") or "").lower(), author.get("__typename"))):
|
|
84
|
+
mentions.append(src["number"])
|
|
85
|
+
return ({"state": issue["state"], "title": issue["title"], "assignees": issue["assignees"]["totalCount"],
|
|
86
|
+
"linked_open_prs": len(linked), "open_prs_mentioning": mentions,
|
|
87
|
+
"labels": [lb["name"] for lb in issue["labels"]["nodes"] if lb], "updated_at": issue["updatedAt"],
|
|
88
|
+
"source": "github graphql"}, comments)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _finish(status: dict, comments: list[dict], now: datetime) -> dict:
|
|
92
|
+
claims, waits, older = classify_comments(comments, now)
|
|
93
|
+
not_ready = [lb for lb in status["labels"] if re.search(WAIT_LABEL_PATTERN, norm_label(lb))]
|
|
94
|
+
untriaged = [lb for lb in status["labels"] if re.search(TRIAGE_LABEL_PATTERN, norm_label(lb))]
|
|
95
|
+
invited = has_opportunity(status["labels"])
|
|
96
|
+
mentioning = status.get("open_prs_mentioning") or []
|
|
97
|
+
reasons = [r for r, hit in (("closed", status["state"] != "OPEN"), ("assigned", status["assignees"] > 0),
|
|
98
|
+
("open PR", bool(status["linked_open_prs"])),
|
|
99
|
+
(f"open PR mentions it ({', '.join(f'#{n}' for n in mentioning)})", bool(mentioning)),
|
|
100
|
+
("claimed recently", bool(claims)), ("maintainer asked to wait", bool(waits)),
|
|
101
|
+
(f"label says not ready ({', '.join(not_ready)})", bool(not_ready)),
|
|
102
|
+
(f"label says not settled yet ({', '.join(untriaged)})", bool(untriaged) and not invited))
|
|
103
|
+
if hit]
|
|
104
|
+
mentioned = list(dict.fromkeys(int(n) for c in comments for n in PR_MENTION.findall(c.get("body") or "")))
|
|
105
|
+
notes = [status["note"]] if status.get("note") else []
|
|
106
|
+
if mentioned and not reasons:
|
|
107
|
+
notes.append(f"comments mention PR {', '.join(f'#{n}' for n in mentioned)}; check it is not already fixing this")
|
|
108
|
+
if untriaged and invited and not reasons:
|
|
109
|
+
notes.append(f"also labelled '{untriaged[0]}': a maintainer may not have confirmed it yet; ask before starting")
|
|
110
|
+
out = {**status, "claims": claims, "waits": waits, "older_claims": older, "mentioned_prs": mentioned,
|
|
111
|
+
"available": not reasons, "reasons": reasons, "checked_at": now.strftime("%Y-%m-%dT%H:%M:%SZ")}
|
|
112
|
+
if notes:
|
|
113
|
+
out["note"] = "; ".join(notes)
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class GitHubLive:
|
|
118
|
+
def __init__(self, token: str | None = None, client: httpx.Client | None = None):
|
|
119
|
+
self.token = (token or "").strip() or None
|
|
120
|
+
# Renamed repos and moved issues answer with redirects.
|
|
121
|
+
self.client = client or httpx.Client(timeout=30, follow_redirects=True)
|
|
122
|
+
self.headers = {"Accept": "application/vnd.github+json", "User-Agent": "mergescout"}
|
|
123
|
+
if self.token:
|
|
124
|
+
self.headers["Authorization"] = f"Bearer {self.token}"
|
|
125
|
+
|
|
126
|
+
def _get(self, path: str, params=None) -> httpx.Response:
|
|
127
|
+
resp = self.client.get(f"{API}{path}", headers=self.headers, params=params)
|
|
128
|
+
if resp.status_code == 404:
|
|
129
|
+
raise LiveError("GitHub says this issue or repo does not exist (or is private).")
|
|
130
|
+
if resp.status_code in (401, 403, 429):
|
|
131
|
+
raise LiveError(f"GitHub refused the live check (HTTP {resp.status_code}); set MERGESCOUT_TOKEN for more calls.")
|
|
132
|
+
resp.raise_for_status()
|
|
133
|
+
return resp
|
|
134
|
+
|
|
135
|
+
def _post(self, query: str, variables: dict) -> dict:
|
|
136
|
+
resp = self.client.post(f"{API}/graphql", headers=self.headers, json={"query": query, "variables": variables})
|
|
137
|
+
if resp.status_code == 401:
|
|
138
|
+
raise LiveError("GitHub rejected the token (401).")
|
|
139
|
+
if resp.status_code >= 400:
|
|
140
|
+
raise LiveError(f"GitHub could not answer the live check (HTTP {resp.status_code}).")
|
|
141
|
+
return resp.json()
|
|
142
|
+
|
|
143
|
+
def issue_status(self, owner: str, name: str, number: int) -> dict:
|
|
144
|
+
now = datetime.now(timezone.utc)
|
|
145
|
+
if self.token:
|
|
146
|
+
body = self._post(STATUS_QUERY, {"owner": owner, "name": name, "number": number})
|
|
147
|
+
errors = _graphql_errors(body)
|
|
148
|
+
issue = ((body.get("data") or {}).get("repository") or {}).get("issue")
|
|
149
|
+
if issue is None:
|
|
150
|
+
if errors and not all(e.get("type") == "NOT_FOUND" for e in errors):
|
|
151
|
+
raise LiveError("GitHub could not answer: " + "; ".join(e.get("message", "?") for e in errors))
|
|
152
|
+
raise LiveError("GitHub says this issue does not exist (or is a pull request).")
|
|
153
|
+
status, comments = _from_graphql(issue, f"{owner}/{name}")
|
|
154
|
+
else:
|
|
155
|
+
base = self._get(f"/repos/{owner}/{name}/issues/{number}").json()
|
|
156
|
+
if "pull_request" in base:
|
|
157
|
+
raise LiveError("That number is a pull request, not an issue.")
|
|
158
|
+
last = max(1, math.ceil((base.get("comments") or 0) / 100))
|
|
159
|
+
raw = self._get(f"/repos/{owner}/{name}/issues/{number}/comments", {"per_page": 100, "page": last}).json()
|
|
160
|
+
comments = [{"login": (c.get("user") or {}).get("login"), "type": (c.get("user") or {}).get("type"),
|
|
161
|
+
"association": c.get("author_association"), "body": c.get("body"), "created_at": c.get("created_at")}
|
|
162
|
+
for c in raw[-10:]]
|
|
163
|
+
status = {"state": base["state"].upper(), "title": base["title"], "assignees": len(base.get("assignees") or []),
|
|
164
|
+
"linked_open_prs": None, "labels": [lb["name"] for lb in base.get("labels") or []],
|
|
165
|
+
"updated_at": base.get("updated_at"), "source": "github rest",
|
|
166
|
+
"note": "linked PRs unknown without a token (set MERGESCOUT_TOKEN to check them)"}
|
|
167
|
+
return _finish(status, comments, now)
|
|
168
|
+
|
|
169
|
+
def issue_statuses(self, keys: list[tuple[str, str, int]]) -> dict[tuple[str, str, int], dict | None]:
|
|
170
|
+
# One GraphQL call per 25 issues, so a whole result list is checked for about one rate-limit point.
|
|
171
|
+
if not self.token:
|
|
172
|
+
raise LiveError("checking many issues at once needs MERGESCOUT_TOKEN")
|
|
173
|
+
now, out = datetime.now(timezone.utc), {}
|
|
174
|
+
for start in range(0, len(keys), BATCH):
|
|
175
|
+
chunk = keys[start:start + BATCH]
|
|
176
|
+
args = ", ".join(f"$o{n}: String!, $n{n}: String!, $k{n}: Int!" for n in range(len(chunk)))
|
|
177
|
+
parts = " ".join(f"i{n}: repository(owner: $o{n}, name: $n{n}) {{ issue(number: $k{n}) {{ ...S }} }}"
|
|
178
|
+
for n in range(len(chunk)))
|
|
179
|
+
variables = {}
|
|
180
|
+
for n, (owner, name, number) in enumerate(chunk):
|
|
181
|
+
variables.update({f"o{n}": owner, f"n{n}": name, f"k{n}": number})
|
|
182
|
+
body = self._post(f"query({args}) {{ rateLimit {{ cost remaining }} {parts} }}" + FIELDS, variables)
|
|
183
|
+
_graphql_errors(body)
|
|
184
|
+
data = body.get("data") or {}
|
|
185
|
+
if not data:
|
|
186
|
+
raise LiveError("GitHub could not answer the live check.")
|
|
187
|
+
for n, key in enumerate(chunk):
|
|
188
|
+
issue = (data.get(f"i{n}") or {}).get("issue")
|
|
189
|
+
out[key] = _finish(*_from_graphql(issue, f"{key[0]}/{key[1]}"), now) if issue else None
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
def fresh_issues(self, language: str | None, since: datetime, limit: int = 20) -> list[dict]:
|
|
193
|
+
# Issues opened after the snapshot was built: one search call.
|
|
194
|
+
q = f'is:issue is:open no:assignee label:"good first issue","help wanted" created:>={since:%Y-%m-%dT%H:%M:%SZ}'
|
|
195
|
+
if language:
|
|
196
|
+
q += f' language:"{language}"' if " " in language else f" language:{language}"
|
|
197
|
+
items = self._get("/search/issues", {"q": q, "sort": "created", "order": "desc",
|
|
198
|
+
"per_page": max(1, min(limit * 3, 100))}).json().get("items") or []
|
|
199
|
+
return [{"repo": it["repository_url"].split("/repos/", 1)[1], "number": it["number"], "title": it["title"],
|
|
200
|
+
"labels": [lb["name"] for lb in it.get("labels") or []], "created_at": it["created_at"],
|
|
201
|
+
"url": it["html_url"], "fresh": True} for it in items]
|