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,293 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import sqlite3
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mergescout.labels import BEGINNER, norm_label
|
|
8
|
+
from mergescout.signals.rules import TRIAGE_LABEL_PATTERN
|
|
9
|
+
|
|
10
|
+
# Letters and digits only, like the index's tokenizer: "snake_case" quoted whole is a phrase, which detail=column rejects.
|
|
11
|
+
WORD = re.compile(r"[^\W_]+")
|
|
12
|
+
STOP = {"the", "and", "for", "with", "to", "of", "in", "on", "an", "is", "it", "be", "or", "not", "this", "that",
|
|
13
|
+
"from", "by", "at", "as"}
|
|
14
|
+
NOTE = "Availability is from the snapshot; call check_issue before telling someone an issue is free."
|
|
15
|
+
PARTS = {"availability": "p_avail", "outsider_merge": "p_merge", "reply": "p_reply", "fixed_before": "p_fixed",
|
|
16
|
+
"issue_quality": "p_quality", "multiplier": "mult"}
|
|
17
|
+
# Real queries (2026-10-16): "database" missed issues titled "SQLite lock" or "Postgres timeout".
|
|
18
|
+
_DB = ("db", "sql", "sqlite", "postgres", "postgresql", "mysql", "mongodb", "redis", "orm")
|
|
19
|
+
_DOCS = ("doc", "docs", "documentation", "readme", "typo", "tutorial", "guide")
|
|
20
|
+
SYNONYMS = {"database": _DB, "db": _DB, "sql": _DB, "doc": _DOCS, "docs": _DOCS, "documentation": _DOCS,
|
|
21
|
+
"test": ("tests", "testing", "pytest", "unittest", "coverage"), "testing": ("test", "tests", "pytest"),
|
|
22
|
+
"cli": ("command", "terminal", "flag", "subcommand"), "ui": ("frontend", "css", "component", "layout"),
|
|
23
|
+
"frontend": ("ui", "css", "component"), "api": ("endpoint", "rest", "http"),
|
|
24
|
+
"auth": ("login", "oauth", "authentication"), "login": ("auth", "oauth", "signin"),
|
|
25
|
+
# Not "cache" or "memory": real titles use them for features ("X-Cache header", "load into memory").
|
|
26
|
+
"performance": ("slow", "speed", "perf", "latency", "optimize", "benchmark"),
|
|
27
|
+
"perf": ("performance", "slow")}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fts_words(text: str | None) -> list[str]:
|
|
31
|
+
return list(dict.fromkeys(w.lower() for w in WORD.findall(text or "") if len(w) >= 2 and w.lower() not in STOP))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fts_query(text: str | None, synonyms: bool = False) -> str | None:
|
|
35
|
+
# Every word becomes a quoted term, so search syntax in user text is never interpreted.
|
|
36
|
+
words = fts_words(text)
|
|
37
|
+
if synonyms:
|
|
38
|
+
plural = lambda w: SYNONYMS.get(w[:-1], ()) if w.endswith("s") else ()
|
|
39
|
+
words = list(dict.fromkeys(words + [s for w in words for s in SYNONYMS.get(w, plural(w))]))
|
|
40
|
+
return " OR ".join(f'"{w}"' for w in words) or None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parts(r: dict) -> dict:
|
|
44
|
+
return {name: None if r[col] is None else round(r[col], 3) for name, col in PARTS.items()}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _pct(x) -> int:
|
|
48
|
+
return round((x or 0) * 100)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _days(d) -> str:
|
|
52
|
+
return "less than a day" if not d else "the last day" if d == 1 else f"the last {d} days"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _rate(merged, decided, rate, low, high, enough, r: dict, who: str) -> str:
|
|
56
|
+
if not enough:
|
|
57
|
+
return f"not enough evidence yet ({decided} decided {who})"
|
|
58
|
+
# Users read "last 4 days" as a slow repo; say plainly that a busy repo's newest PRs cover a short time.
|
|
59
|
+
span = (f" over {_days(r['window_days'])}" if r["window_complete"]
|
|
60
|
+
else f", from the newest 300 PRs, which cover only {_days(r['window_days'])}")
|
|
61
|
+
return f"{_pct(rate)}% of {who} merged ({merged} of {decided}, likely {_pct(low)}-{_pct(high)}%){span}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def rate_text(r: dict) -> str:
|
|
65
|
+
merged = r["merged"] or 0
|
|
66
|
+
return _rate(merged, merged + (r["closed"] or 0), r["merge_rate"], r["merge_low"], r["merge_high"],
|
|
67
|
+
r["merge_enough"], r, "outsider PRs")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def newcomer_text(r: dict) -> str:
|
|
71
|
+
return _rate(r["newcomer_merged"] or 0, r["newcomer_decided"] or 0, r["newcomer_rate"], r["newcomer_low"],
|
|
72
|
+
r["newcomer_high"], r["newcomer_enough"], r, "first PRs by newcomers")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def review_text(r: dict) -> str:
|
|
76
|
+
if not r["review_enough"]:
|
|
77
|
+
return f"review time: not enough evidence yet ({r['reviewed'] or 0} maintainer reviews)"
|
|
78
|
+
h = r["median_review_hours"]
|
|
79
|
+
return f"maintainers usually first review outsider PRs within ~{h:.1f} h (median of {r['reviewed']})"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Snapshot:
|
|
83
|
+
def __init__(self, path: Path):
|
|
84
|
+
self.db = sqlite3.connect(Path(path).resolve().as_uri() + "?mode=ro", uri=True, check_same_thread=False)
|
|
85
|
+
self.db.row_factory = sqlite3.Row
|
|
86
|
+
self.meta = dict(self.db.execute("SELECT key, value FROM meta").fetchall())
|
|
87
|
+
self.built_at = self.meta.get("built_at")
|
|
88
|
+
# Files from schema 1.0 have no beginner column; "help wanted" then still counts as beginner.
|
|
89
|
+
cols = {c[1] for c in self.db.execute("PRAGMA table_info(issues)")}
|
|
90
|
+
self.beginner_col = "beginner" if "beginner" in cols else "opportunity"
|
|
91
|
+
|
|
92
|
+
def _days_since(self, ts: str | None) -> int | None:
|
|
93
|
+
if not ts or not self.built_at:
|
|
94
|
+
return None
|
|
95
|
+
return (datetime.fromisoformat(self.built_at.replace("Z", "+00:00"))
|
|
96
|
+
- datetime.fromisoformat(ts.replace("Z", "+00:00"))).days
|
|
97
|
+
|
|
98
|
+
def _rows(self, sql: str, params=()) -> list[dict]:
|
|
99
|
+
return [dict(r) for r in self.db.execute(sql, params).fetchall()]
|
|
100
|
+
|
|
101
|
+
def stats(self) -> dict:
|
|
102
|
+
free = self.db.execute("SELECT count(*) FROM issues WHERE available = 1").fetchone()[0]
|
|
103
|
+
langs = self._rows("SELECT language, count(*) AS issues FROM issues WHERE available = 1 AND language IS NOT NULL"
|
|
104
|
+
" GROUP BY language ORDER BY 2 DESC LIMIT 15")
|
|
105
|
+
return {**{k: (int(v) if v.isdigit() else v) for k, v in self.meta.items()}, "free_issues": free,
|
|
106
|
+
"top_languages": langs}
|
|
107
|
+
|
|
108
|
+
def repo(self, name: str) -> dict | None:
|
|
109
|
+
rows = self._rows("SELECT * FROM repos WHERE name = ? COLLATE NOCASE", (name,))
|
|
110
|
+
return rows[0] if rows else None
|
|
111
|
+
|
|
112
|
+
def _row(self, r: dict, detail: bool) -> dict:
|
|
113
|
+
walls = [w["wall"] for w in json.loads(r["walls_json"]) if w.get("blocking")]
|
|
114
|
+
labels = json.loads(r["labels"])
|
|
115
|
+
names = {norm_label(x) for x in labels}
|
|
116
|
+
# The shown score is the one the list is sorted by (base score with freshness), so lists read in order.
|
|
117
|
+
out = {"issue": f"{r['repo']}#{r['number']}", "url": f"https://github.com/{r['repo']}/issues/{r['number']}",
|
|
118
|
+
"title": r["title"], "labels": labels, "score": round(r["rank_score"], 2),
|
|
119
|
+
"level": "beginner label" if names & BEGINNER else "help wanted" if "help wanted" in names
|
|
120
|
+
else "no beginner label",
|
|
121
|
+
"free": bool(r["available"]), "outsider_merge": rate_text(r), "review": review_text(r),
|
|
122
|
+
"walls": walls, "updated": (r["updated_at"] or "")[:10]}
|
|
123
|
+
if r.get("matched"):
|
|
124
|
+
out["matched"] = r["matched"]
|
|
125
|
+
untriaged = [x for x in labels if re.search(TRIAGE_LABEL_PATTERN, norm_label(x))]
|
|
126
|
+
if untriaged and r["available"]:
|
|
127
|
+
out["warning"] = (f"also labelled '{untriaged[0]}': a maintainer may not have confirmed it yet;"
|
|
128
|
+
" ask before starting")
|
|
129
|
+
if r.get("kind") == "discussion":
|
|
130
|
+
out["kind"] = "discussion or tracker, not a ready task"
|
|
131
|
+
if not r["available"]:
|
|
132
|
+
out["taken"] = r["blocked_by"]
|
|
133
|
+
days = self._days_since(r["last_claim_at"])
|
|
134
|
+
if r["claimers"] and days is not None:
|
|
135
|
+
out["earlier_claims"] = (f"{r['claimers']} person(s) offered to work on it, the last {days} days ago;"
|
|
136
|
+
" ask on the issue before starting")
|
|
137
|
+
if detail:
|
|
138
|
+
out.update({"base_score": round(r["score"], 2), "score_parts": parts(r), "notes": json.loads(r["repo_notes"]),
|
|
139
|
+
"stars": r["stars"], "dead_ends": r["dead_ends"], "interest_30d": r["interest_30d"],
|
|
140
|
+
"body": r["body"]})
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
def _select(self, where: str, params: dict, order: str, limit: int, join_fts: bool = False) -> list[dict]:
|
|
144
|
+
fts = "JOIN issues_fts f ON f.rowid = i.rowid" if join_fts else ""
|
|
145
|
+
return self._rows(f"SELECT i.*, r.name AS repo, r.stars, r.merged, r.closed, r.merge_rate, r.merge_low,"
|
|
146
|
+
f" r.merge_high, r.merge_enough, r.window_days, r.window_complete, r.median_review_hours,"
|
|
147
|
+
f" r.review_enough, r.reviewed, r.walls_json, r.notes AS repo_notes FROM issues i {fts}"
|
|
148
|
+
f" JOIN repos r USING (repo_id)"
|
|
149
|
+
f" WHERE {where} ORDER BY {order}" + ("" if limit is None else " LIMIT :limit"),
|
|
150
|
+
params if limit is None else {**params, "limit": limit})
|
|
151
|
+
|
|
152
|
+
def find_issues(self, language=None, topic=None, beginner_labels_only=False, min_score=0.0, allow_walls=True,
|
|
153
|
+
limit=10, detail=False) -> dict:
|
|
154
|
+
# min_score applies to the score users see in the list (with freshness).
|
|
155
|
+
where = ["i.available = 1", "i.rank_score >= :min_score"]
|
|
156
|
+
params = {"min_score": min_score}
|
|
157
|
+
if language:
|
|
158
|
+
where.append("i.language = :language")
|
|
159
|
+
params["language"] = language
|
|
160
|
+
if beginner_labels_only:
|
|
161
|
+
where.append(f"i.{self.beginner_col} = 1")
|
|
162
|
+
if not allow_walls:
|
|
163
|
+
where.append("r.blocking_walls = 0")
|
|
164
|
+
q = fts_query(topic, synonyms=True)
|
|
165
|
+
if topic and not q:
|
|
166
|
+
return {"snapshot": self.built_at, "results": [],
|
|
167
|
+
"note": "The topic had no searchable words (very common words are ignored); try other words."}
|
|
168
|
+
limit = max(1, min(limit, 50))
|
|
169
|
+
state = {"seen": {}, "picked": [], "keys": set()}
|
|
170
|
+
if q:
|
|
171
|
+
# Real queries (2026-10-16): only 19 of 40 topic results had the topic in the title; those come first.
|
|
172
|
+
for matched, match in (("title or label", f"{{title labels}} : ({q})"), ("text", q)):
|
|
173
|
+
self._fill(" AND ".join(where + ["issues_fts MATCH :q"]), {**params, "q": match}, limit, state,
|
|
174
|
+
matched)
|
|
175
|
+
else:
|
|
176
|
+
self._fill(" AND ".join(where), params, limit, state, None)
|
|
177
|
+
return {"snapshot": self.built_at, "note": NOTE, "results": [self._row(r, detail) for r in state["picked"]]}
|
|
178
|
+
|
|
179
|
+
def _fill(self, where: str, params: dict, limit: int, state: dict, matched: str | None) -> None:
|
|
180
|
+
# Real data (2026-10-15): one welcoming repo filled the whole top list; show at most 2 per repo.
|
|
181
|
+
# Pages of the ranked index are read until the list is full, so a dominant repo cannot shorten it.
|
|
182
|
+
seen, picked, page, offset = state["seen"], state["picked"], limit * 5, 0
|
|
183
|
+
while len(picked) < limit and offset < 5000:
|
|
184
|
+
rows = self._select(where, params, f"i.rank_score DESC LIMIT {page} OFFSET {offset}", None,
|
|
185
|
+
matched is not None)
|
|
186
|
+
for r in rows:
|
|
187
|
+
key = (r["repo_id"], r["number"])
|
|
188
|
+
if key in state["keys"] or seen.get(r["repo_id"], 0) >= 2 or len(picked) >= limit:
|
|
189
|
+
continue
|
|
190
|
+
seen[r["repo_id"]] = seen.get(r["repo_id"], 0) + 1
|
|
191
|
+
state["keys"].add(key)
|
|
192
|
+
picked.append({**r, "matched": matched} if matched else r)
|
|
193
|
+
if len(rows) < page:
|
|
194
|
+
break
|
|
195
|
+
offset += page
|
|
196
|
+
|
|
197
|
+
def issue(self, name: str, number: int) -> dict | None:
|
|
198
|
+
rows = self._select("r.name = :name COLLATE NOCASE AND i.number = :number", {"name": name, "number": number},
|
|
199
|
+
"i.number", 1)
|
|
200
|
+
return rows[0] if rows else None
|
|
201
|
+
|
|
202
|
+
def examples(self, repo_id: int) -> list[dict]:
|
|
203
|
+
return self._rows("SELECT number, merged_at, lines, files, days_to_merge FROM outsider_prs WHERE repo_id = ?"
|
|
204
|
+
" ORDER BY merged_at DESC", (repo_id,))
|
|
205
|
+
|
|
206
|
+
def explain(self, name: str, number: int) -> dict | None:
|
|
207
|
+
r = self.issue(name, number)
|
|
208
|
+
repo = self.repo(name)
|
|
209
|
+
if r is None or repo is None:
|
|
210
|
+
return None
|
|
211
|
+
labels = {lb["label"]: lb for lb in json.loads(repo["labels_json"])}
|
|
212
|
+
return {"version": 1, "snapshot": self.built_at, "source": {"fetched_at": r["fetched_at"]},
|
|
213
|
+
"issue": {"ref": f"{repo['name']}#{number}", "title": r["title"], "labels": json.loads(r["labels"]),
|
|
214
|
+
"assignees": r["assignees"], "linked_open_prs": r["linked_open_prs"],
|
|
215
|
+
"dead_ends": r["dead_ends"], "interest_30d": r["interest_30d"]},
|
|
216
|
+
"availability": {"available": bool(r["available"]),
|
|
217
|
+
"blocked_by": [b for b in (r["blocked_by"] or "").split(", ") if b],
|
|
218
|
+
"last_claim_at": r["last_claim_at"], "claimers": r["claimers"]},
|
|
219
|
+
"repo": {"outsider_merge": {"merged": repo["merged"], "decided": (repo["merged"] or 0) + (repo["closed"] or 0),
|
|
220
|
+
"rate": repo["merge_rate"], "low": repo["merge_low"],
|
|
221
|
+
"high": repo["merge_high"], "enough": bool(repo["merge_enough"]),
|
|
222
|
+
"window_days": repo["window_days"],
|
|
223
|
+
"window_complete": bool(repo["window_complete"])},
|
|
224
|
+
"newcomer_merge": {"merged": repo["newcomer_merged"], "decided": repo["newcomer_decided"],
|
|
225
|
+
"rate": repo["newcomer_rate"], "low": repo["newcomer_low"],
|
|
226
|
+
"high": repo["newcomer_high"], "enough": bool(repo["newcomer_enough"])},
|
|
227
|
+
"review_hours": {"median": repo["median_review_hours"], "n": repo["reviewed"],
|
|
228
|
+
"enough": bool(repo["review_enough"])},
|
|
229
|
+
"excluded": {"bot_prs": repo["bot_prs"], "ai_prs": repo["ai_prs"],
|
|
230
|
+
"insider_prs": repo["insider_prs"]}},
|
|
231
|
+
"labels": [labels[lb] for lb in dict.fromkeys(norm_label(x) for x in json.loads(r["labels"]))
|
|
232
|
+
if lb in labels],
|
|
233
|
+
"walls": json.loads(repo["walls_json"]),
|
|
234
|
+
"score": {**parts(r), "total": round(r["score"], 3), "in_lists": round(r["rank_score"], 3)},
|
|
235
|
+
"notes": json.loads(repo["notes"])}
|
|
236
|
+
|
|
237
|
+
def repo_health(self, name: str, detail: bool = False) -> dict | None:
|
|
238
|
+
r = self.repo(name)
|
|
239
|
+
if r is None:
|
|
240
|
+
return None
|
|
241
|
+
counts = self.db.execute("SELECT count(*) FILTER (WHERE opportunity = 1),"
|
|
242
|
+
" count(*) FILTER (WHERE opportunity = 1 AND available = 1) FROM issues WHERE repo_id = ?",
|
|
243
|
+
(r["repo_id"],)).fetchone()
|
|
244
|
+
out = {"repo": r["name"], "url": f"https://github.com/{r['name']}", "stars": r["stars"], "language": r["language"],
|
|
245
|
+
"outsider_merge": rate_text(r), "newcomer_merge": newcomer_text(r),
|
|
246
|
+
"review": review_text(r), "insiders": r["insiders"],
|
|
247
|
+
"excluded_prs": {"bots": r["bot_prs"], "ai_agents": r["ai_prs"], "insiders": r["insider_prs"]},
|
|
248
|
+
"walls": json.loads(r["walls_json"]), "beginner_issues": {"open": counts[0], "free": counts[1]},
|
|
249
|
+
"how_to_reach": {"discussions": bool(r["has_discussions"]),
|
|
250
|
+
"contributing": f"https://github.com/{r['name']}/blob/HEAD/CONTRIBUTING.md"
|
|
251
|
+
if r["contributing"] else None,
|
|
252
|
+
"issues": f"https://github.com/{r['name']}/issues"},
|
|
253
|
+
"snapshot": self.built_at}
|
|
254
|
+
if detail:
|
|
255
|
+
out["labels"] = json.loads(r["labels_json"])
|
|
256
|
+
out["accepted_outsider_prs"] = self.examples(r["repo_id"])
|
|
257
|
+
return out
|
|
258
|
+
|
|
259
|
+
def repos_needing_help(self, language=None, limit=10) -> dict:
|
|
260
|
+
# Welcoming to outsiders (enough evidence, merge rate at least 50% even at the low end) but few maintainers.
|
|
261
|
+
built = datetime.fromisoformat(self.built_at.replace("Z", "+00:00")) if self.built_at else datetime.now(timezone.utc)
|
|
262
|
+
recent = (built - timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
263
|
+
rows = self._rows("SELECT * FROM repos WHERE merge_enough = 1 AND merge_low >= 0.5 AND insiders <= 3"
|
|
264
|
+
" AND coalesce(archived, 0) = 0 AND pushed_at >= :recent"
|
|
265
|
+
" AND (:language IS NULL OR language = :language) ORDER BY outsider_prs DESC LIMIT :limit",
|
|
266
|
+
{"recent": recent, "language": language, "limit": max(1, min(limit, 50))})
|
|
267
|
+
return {"snapshot": self.built_at, "results": [
|
|
268
|
+
{"repo": r["name"], "url": f"https://github.com/{r['name']}", "language": r["language"],
|
|
269
|
+
"why": f"{r['insiders']} active maintainers handle {r['outsider_prs']} outsider PRs; {rate_text(r)};"
|
|
270
|
+
f" {review_text(r)}"} for r in rows]}
|
|
271
|
+
|
|
272
|
+
def similar_issues(self, name: str, number: int, limit: int = 5) -> dict:
|
|
273
|
+
base = self.issue(name, number)
|
|
274
|
+
if base is None:
|
|
275
|
+
return {"snapshot": self.built_at, "results": []}
|
|
276
|
+
# The rarest title words that other issues share: common words ("add", "error") match thousands and are slow.
|
|
277
|
+
counts = {w: self.db.execute("SELECT count(*) FROM issues_fts WHERE issues_fts MATCH ?", (f'"{w}"',)).fetchone()[0]
|
|
278
|
+
for w in fts_words(base["title"])}
|
|
279
|
+
rare = [w for w, n in sorted(counts.items(), key=lambda kv: kv[1]) if n >= 2][:3]
|
|
280
|
+
q = " OR ".join(f'"{w}"' for w in rare) or None
|
|
281
|
+
rows = self._select("issues_fts MATCH :q AND i.available = 1 AND NOT (i.repo_id = :rid AND i.number = :n)",
|
|
282
|
+
{"q": q, "rid": base["repo_id"], "n": number}, "bm25(issues_fts), i.rank_score DESC",
|
|
283
|
+
max(1, min(limit, 20)), True) if q else []
|
|
284
|
+
return {"snapshot": self.built_at, "results": [self._row(r, False) for r in rows]}
|
|
285
|
+
|
|
286
|
+
def with_repo_stats(self, items: list[dict]) -> list[dict]:
|
|
287
|
+
out = []
|
|
288
|
+
for it in items:
|
|
289
|
+
r = self.repo(it["repo"])
|
|
290
|
+
if r is not None:
|
|
291
|
+
out.append({**it, "outsider_merge": rate_text(r), "review": review_text(r),
|
|
292
|
+
"walls": [w["wall"] for w in json.loads(r["walls_json"]) if w.get("blocking")]})
|
|
293
|
+
return out
|
mergescout/tool/refs.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
REF = re.compile(r"(?:https?://)?(?:www\.)?(?:github\.com/)?([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)"
|
|
5
|
+
r"(?:\.git)?(?:(?:/issues/|/pull/|#)(\d+))?/?$")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Ref:
|
|
10
|
+
owner: str
|
|
11
|
+
name: str
|
|
12
|
+
number: int | None
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def repo(self) -> str:
|
|
16
|
+
return f"{self.owner}/{self.name}"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def key(self) -> str:
|
|
20
|
+
return f"{self.repo}#{self.number}" if self.number else self.repo
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_ref(text: str) -> Ref:
|
|
24
|
+
s = re.sub(r"\?.*$", "", (text or "").strip())
|
|
25
|
+
# "#issuecomment-123" and other anchors are not part of the issue reference; "#7" is.
|
|
26
|
+
s = re.sub(r"#(?!\d+/?$).*$", "", s)
|
|
27
|
+
s = re.sub(r"\s+#", "#", s)
|
|
28
|
+
m = REF.match(s)
|
|
29
|
+
if not m:
|
|
30
|
+
raise ValueError(f"Could not read {text!r}; use owner/name#number or a GitHub issue URL.")
|
|
31
|
+
return Ref(m.group(1), m.group(2), int(m.group(3)) if m.group(3) else None)
|
mergescout/tool/store.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sqlite3
|
|
4
|
+
import threading
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import zstandard
|
|
9
|
+
|
|
10
|
+
from mergescout.snapshot.publish import MANIFEST, PACKED, PREFIX, SNAPSHOT, sha256
|
|
11
|
+
from mergescout.snapshot.schema import SCHEMA_MAJOR
|
|
12
|
+
|
|
13
|
+
DEFAULT_REPO = "kaushik-yadav/merge-scout"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def home() -> Path:
|
|
17
|
+
root = Path(os.environ.get("MERGESCOUT_HOME") or Path.home() / ".mergescout")
|
|
18
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
return root
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def data_token() -> str | None:
|
|
23
|
+
# The same names the rest of the suite reads, so one exported token serves every command.
|
|
24
|
+
for name in ("MERGESCOUT_TOKEN", "STACKVITALS_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"):
|
|
25
|
+
if os.environ.get(name, "").strip():
|
|
26
|
+
return os.environ[name].strip()
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _now() -> str:
|
|
31
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _major(version) -> int:
|
|
35
|
+
return int(str(version).split(".")[0])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SnapshotStore:
|
|
39
|
+
def __init__(self, root: Path, releases):
|
|
40
|
+
self.root, self.releases = root, releases
|
|
41
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
self.path = root / SNAPSHOT
|
|
43
|
+
|
|
44
|
+
def local_manifest(self) -> dict | None:
|
|
45
|
+
p = self.root / MANIFEST
|
|
46
|
+
if not (p.exists() and self.path.exists()):
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
50
|
+
except (ValueError, OSError):
|
|
51
|
+
# A cut-off manifest only costs a fresh check, never a locked-out user.
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def _tmp(self, name: str) -> Path:
|
|
55
|
+
# Per process and thread, so two AI tools starting together never share a half-written file.
|
|
56
|
+
return self.root / f"{name}.{os.getpid()}.{threading.get_ident()}"
|
|
57
|
+
|
|
58
|
+
def _remember(self, manifest: dict) -> None:
|
|
59
|
+
tmp = self._tmp(MANIFEST)
|
|
60
|
+
tmp.write_text(json.dumps({**manifest, "checked_at": _now()}), encoding="utf-8")
|
|
61
|
+
os.replace(tmp, self.root / MANIFEST)
|
|
62
|
+
|
|
63
|
+
def _latest(self) -> tuple[dict, dict] | None:
|
|
64
|
+
tags = sorted((r for r in self.releases.list_releases() if r["tag_name"].startswith(PREFIX)),
|
|
65
|
+
key=lambda r: r["tag_name"], reverse=True)
|
|
66
|
+
for release in tags:
|
|
67
|
+
assets = {a["name"]: a for a in self.releases.assets(release)}
|
|
68
|
+
if MANIFEST in assets and PACKED in assets:
|
|
69
|
+
tmp = self._tmp("remote-manifest.json")
|
|
70
|
+
try:
|
|
71
|
+
self.releases.download_asset(assets[MANIFEST], tmp)
|
|
72
|
+
manifest = json.loads(tmp.read_text(encoding="utf-8"))
|
|
73
|
+
finally:
|
|
74
|
+
tmp.unlink(missing_ok=True)
|
|
75
|
+
return {**manifest, "tag": release["tag_name"]}, assets[PACKED]
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def update(self, force: bool = False) -> str:
|
|
79
|
+
found = self._latest()
|
|
80
|
+
if found is None:
|
|
81
|
+
return "no snapshot is published yet"
|
|
82
|
+
manifest, asset = found
|
|
83
|
+
local = self.local_manifest()
|
|
84
|
+
if local and local.get("sha256") == manifest["sha256"] and not force:
|
|
85
|
+
self._remember(local)
|
|
86
|
+
return f"up to date ({local['built_at']})"
|
|
87
|
+
|
|
88
|
+
def refuse(reason: str) -> str:
|
|
89
|
+
# Remember the check, so a refused file is not downloaded again on every start.
|
|
90
|
+
if local:
|
|
91
|
+
self._remember(local)
|
|
92
|
+
return f"kept the current file: {reason}"
|
|
93
|
+
return f"no snapshot installed: {reason}"
|
|
94
|
+
|
|
95
|
+
if _major(manifest["schema_version"]) != SCHEMA_MAJOR:
|
|
96
|
+
return refuse(f"the new snapshot needs a newer mergescout (schema {manifest['schema_version']})")
|
|
97
|
+
packed, fresh = self._tmp(PACKED + ".part"), self._tmp(SNAPSHOT + ".new")
|
|
98
|
+
try:
|
|
99
|
+
self.releases.download_asset(asset, packed)
|
|
100
|
+
if sha256(packed) != manifest["sha256"]:
|
|
101
|
+
return refuse("the download did not match its checksum")
|
|
102
|
+
with packed.open("rb") as fi, fresh.open("wb") as fo:
|
|
103
|
+
zstandard.ZstdDecompressor().copy_stream(fi, fo)
|
|
104
|
+
db = sqlite3.connect(fresh)
|
|
105
|
+
ok = db.execute("PRAGMA quick_check").fetchone()[0] == "ok"
|
|
106
|
+
version = dict(db.execute("SELECT key, value FROM meta").fetchall()).get("schema_version", "0")
|
|
107
|
+
db.close()
|
|
108
|
+
if not ok or _major(version) != SCHEMA_MAJOR:
|
|
109
|
+
return refuse("the new snapshot failed its check")
|
|
110
|
+
try:
|
|
111
|
+
os.replace(fresh, self.path)
|
|
112
|
+
except PermissionError:
|
|
113
|
+
return refuse("it is in use; restart your AI tool to load the new one")
|
|
114
|
+
self._remember(manifest)
|
|
115
|
+
return f"updated to {manifest['built_at']}"
|
|
116
|
+
finally:
|
|
117
|
+
packed.unlink(missing_ok=True)
|
|
118
|
+
fresh.unlink(missing_ok=True)
|
|
119
|
+
|
|
120
|
+
def ensure(self, max_age_hours: float = 24, log=print) -> Path | None:
|
|
121
|
+
local = self.local_manifest()
|
|
122
|
+
stamp = (local or {}).get("checked_at")
|
|
123
|
+
checked = datetime.fromisoformat(stamp.replace("Z", "+00:00")) if stamp else None
|
|
124
|
+
if checked is None or (datetime.now(timezone.utc) - checked).total_seconds() / 3600 >= max_age_hours:
|
|
125
|
+
try:
|
|
126
|
+
log(self.update())
|
|
127
|
+
except Exception as exc: # noqa: BLE001 - any failure keeps the last good file
|
|
128
|
+
log(f"could not check for a new snapshot: {exc}")
|
|
129
|
+
return self.path if self.path.exists() else None
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from mergescout.tool.live import LiveError
|
|
9
|
+
from mergescout.tool.refs import parse_ref
|
|
10
|
+
|
|
11
|
+
OUTCOMES = ("started", "pr_opened", "merged", "closed_unmerged", "abandoned", "taken_by_someone_else")
|
|
12
|
+
WATCH = ("state", "assignees", "linked_open_prs")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _now() -> str:
|
|
16
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Tracker:
|
|
20
|
+
# Tracked issues and outcomes stay on this machine; nothing is sent anywhere.
|
|
21
|
+
def __init__(self, root: Path):
|
|
22
|
+
self.root = root
|
|
23
|
+
self.file, self.outcomes = root / "tracked.json", root / "outcomes.jsonl"
|
|
24
|
+
|
|
25
|
+
def tracked(self) -> dict:
|
|
26
|
+
if not self.file.exists():
|
|
27
|
+
return {}
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(self.file.read_text(encoding="utf-8"))
|
|
30
|
+
except ValueError:
|
|
31
|
+
# A damaged file is kept aside for the user and tracking starts fresh.
|
|
32
|
+
os.replace(self.file, self.file.with_name(self.file.name + ".bad"))
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
def _save(self, data: dict) -> None:
|
|
36
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
tmp = self.file.with_name(f"{self.file.name}.{os.getpid()}.tmp")
|
|
38
|
+
tmp.write_text(json.dumps(data, indent=1), encoding="utf-8")
|
|
39
|
+
os.replace(tmp, self.file)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _view(status: dict) -> dict:
|
|
43
|
+
return {**{k: status.get(k) for k in WATCH}, "claims": len(status.get("claims") or []),
|
|
44
|
+
"waits": len(status.get("waits") or [])}
|
|
45
|
+
|
|
46
|
+
def track(self, key: str, status: dict) -> dict:
|
|
47
|
+
# GitHub names ignore letter case, so tracked keys do too.
|
|
48
|
+
key = key.lower()
|
|
49
|
+
data = self.tracked()
|
|
50
|
+
data[key] = {"tracked_at": data.get(key, {}).get("tracked_at", _now()), "last": self._view(status)}
|
|
51
|
+
self._save(data)
|
|
52
|
+
return data[key]
|
|
53
|
+
|
|
54
|
+
def untrack(self, key: str) -> None:
|
|
55
|
+
data = self.tracked()
|
|
56
|
+
data.pop(key.lower(), None)
|
|
57
|
+
self._save(data)
|
|
58
|
+
|
|
59
|
+
def updates(self, live) -> list[dict]:
|
|
60
|
+
data, changes = self.tracked(), []
|
|
61
|
+
for key, item in data.items():
|
|
62
|
+
ref = parse_ref(key)
|
|
63
|
+
try:
|
|
64
|
+
now = self._view(live.issue_status(ref.owner, ref.name, ref.number))
|
|
65
|
+
except (LiveError, httpx.HTTPError) as exc:
|
|
66
|
+
# One deleted issue or a rate limit must not hide the others.
|
|
67
|
+
changes.append({"issue": key, "error": str(exc)})
|
|
68
|
+
continue
|
|
69
|
+
diff = {k: [item["last"].get(k), v] for k, v in now.items() if item["last"].get(k) != v}
|
|
70
|
+
if diff:
|
|
71
|
+
changes.append({"issue": key, "changed": diff})
|
|
72
|
+
item["last"] = now
|
|
73
|
+
self._save(data)
|
|
74
|
+
return changes
|
|
75
|
+
|
|
76
|
+
def report_outcome(self, key: str, outcome: str, note: str = "") -> dict:
|
|
77
|
+
if outcome not in OUTCOMES:
|
|
78
|
+
raise ValueError(f"Outcome must be one of: {', '.join(OUTCOMES)}.")
|
|
79
|
+
entry = {"issue": key.lower(), "outcome": outcome, "note": note[:500], "at": _now()}
|
|
80
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
with self.outcomes.open("a", encoding="utf-8") as f:
|
|
82
|
+
f.write(json.dumps(entry) + "\n")
|
|
83
|
+
return entry
|
stackvitals/__init__.py
ADDED
stackvitals/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CI Health: flaky and slow jobs, steps and tests from a repo's GitHub Actions history."""
|