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,120 @@
|
|
|
1
|
+
"""The outside-PR triage model: plain buckets, a small logistic fit and plain reasons. No database or numpy needed,
|
|
2
|
+
so the suite can score PRs with the model file the pipeline publishes."""
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
# The first value of each feature is the reference: its weight is 0 and it is never given as a reason.
|
|
6
|
+
FEATURES = {
|
|
7
|
+
# The author's past here: a first PR, earlier PRs none merged, or merged PRs. One input, so the parts can't overlap.
|
|
8
|
+
"history": ["new", "tried", "1-2", "3+"],
|
|
9
|
+
"size": ["<50", "50-199", "200-999", "1000+"],
|
|
10
|
+
"issue": ["no", "yes"],
|
|
11
|
+
"repo_rate": ["unknown", "<30%", "30-60%", "60-80%", "80%+"],
|
|
12
|
+
}
|
|
13
|
+
WORDS = {
|
|
14
|
+
"history=tried": "has opened PRs here before, none merged yet",
|
|
15
|
+
"history=1-2": "has 1-2 merged PRs in this repo already", "history=3+": "has 3+ merged PRs in this repo already",
|
|
16
|
+
"size=50-199": "changes 50-199 lines", "size=200-999": "changes 200-999 lines", "size=1000+": "changes 1,000+ lines",
|
|
17
|
+
"issue=yes": "links an issue",
|
|
18
|
+
"repo_rate=<30%": "this repo merges under 30% of outside PRs", "repo_rate=30-60%": "this repo merges 30-60% of outside PRs",
|
|
19
|
+
"repo_rate=60-80%": "this repo merges 60-80% of outside PRs", "repo_rate=80%+": "this repo merges 80%+ of outside PRs",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _bucket(value, edges: list[float], names: list[str]) -> str:
|
|
24
|
+
for edge, name in zip(edges, names):
|
|
25
|
+
if value < edge:
|
|
26
|
+
return name
|
|
27
|
+
return names[len(edges)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def buckets(here_merged: int, lines: int, linked_issue: bool, first_timer: bool, repo_rate: float | None) -> dict:
|
|
31
|
+
# An author's record in other repos was tried and dropped: it added nothing on unseen repos, and the live count
|
|
32
|
+
# (all of GitHub) can't match the training count (crawled repos only).
|
|
33
|
+
history = ("new" if first_timer else "tried") if here_merged < 1 else _bucket(here_merged, [3], ["1-2", "3+"])
|
|
34
|
+
return {"history": history, "size": _bucket(lines, [50, 200, 1000], FEATURES["size"]),
|
|
35
|
+
"issue": "yes" if linked_issue else "no",
|
|
36
|
+
"repo_rate": "unknown" if repo_rate is None else _bucket(repo_rate, [0.3, 0.6, 0.8], FEATURES["repo_rate"][1:])}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
COLUMNS = [f"{name}={v}" for name, values in FEATURES.items() for v in values[1:]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _x(feats: dict) -> list[float]:
|
|
43
|
+
return [1.0] + [1.0 if feats.get(c.split("=")[0]) == c.split("=", 1)[1] else 0.0 for c in COLUMNS]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _sigmoid(z: float) -> float:
|
|
47
|
+
return 1 / (1 + math.exp(-max(-30.0, min(30.0, z))))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _solve(a: list[list[float]], b: list[float]) -> list[float]:
|
|
51
|
+
n = len(b)
|
|
52
|
+
m = [row[:] + [b[i]] for i, row in enumerate(a)]
|
|
53
|
+
for col in range(n):
|
|
54
|
+
pivot = max(range(col, n), key=lambda r: abs(m[r][col]))
|
|
55
|
+
m[col], m[pivot] = m[pivot], m[col]
|
|
56
|
+
for r in range(n):
|
|
57
|
+
if r != col and m[col][col]:
|
|
58
|
+
f = m[r][col] / m[col][col]
|
|
59
|
+
m[r] = [x - f * y for x, y in zip(m[r], m[col])]
|
|
60
|
+
return [m[i][n] / m[i][i] if m[i][i] else 0.0 for i in range(n)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fit(rows: list[dict], ridge: float = 1.0, iters: int = 25) -> dict:
|
|
64
|
+
"""Logistic regression on aggregated cells (rows sharing every bucket fit as one), Newton steps with a light ridge."""
|
|
65
|
+
cells = {}
|
|
66
|
+
for r in rows:
|
|
67
|
+
key = tuple(r[name] for name in FEATURES)
|
|
68
|
+
n, k = cells.get(key, (0, 0))
|
|
69
|
+
cells[key] = (n + 1, k + bool(r["merged"]))
|
|
70
|
+
xs = [(_x(dict(zip(FEATURES, key))), n, k) for key, (n, k) in cells.items()]
|
|
71
|
+
w = [0.0] * (len(COLUMNS) + 1)
|
|
72
|
+
for _ in range(iters):
|
|
73
|
+
grad = [0.0] * len(w)
|
|
74
|
+
hess = [[0.0] * len(w) for _ in w]
|
|
75
|
+
for x, n, k in xs:
|
|
76
|
+
p = _sigmoid(sum(a * b for a, b in zip(w, x)))
|
|
77
|
+
for i, xi in enumerate(x):
|
|
78
|
+
if xi:
|
|
79
|
+
grad[i] += xi * (k - n * p)
|
|
80
|
+
for j, xj in enumerate(x):
|
|
81
|
+
if xj:
|
|
82
|
+
hess[i][j] += n * p * (1 - p) * xi * xj
|
|
83
|
+
for i in range(1, len(w)):
|
|
84
|
+
grad[i] -= ridge * w[i]
|
|
85
|
+
hess[i][i] += ridge
|
|
86
|
+
step = _solve(hess, grad)
|
|
87
|
+
w = [a + b for a, b in zip(w, step)]
|
|
88
|
+
if max(abs(s) for s in step) < 1e-6:
|
|
89
|
+
break
|
|
90
|
+
return {"version": 1, "features": FEATURES, "intercept": w[0], "coef": dict(zip(COLUMNS, w[1:]))}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def predict(model: dict, feats: dict) -> float:
|
|
94
|
+
return _sigmoid(model["intercept"] + sum(model["coef"].get(f"{k}={v}", 0.0) for k, v in feats.items()))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def calibration(model: dict, rows: list[dict]) -> dict:
|
|
98
|
+
"""Predicted against actual merge share in ten equal bins, and Brier scores against "the repo's usual rate" alone."""
|
|
99
|
+
scored = sorted(((predict(model, r), bool(r["merged"]), r["repo_rate"]) for r in rows), key=lambda t: t[0])
|
|
100
|
+
deciles = []
|
|
101
|
+
for i in range(10):
|
|
102
|
+
part = scored[i * len(scored) // 10:(i + 1) * len(scored) // 10]
|
|
103
|
+
if part:
|
|
104
|
+
deciles.append({"predicted": round(sum(p for p, _, _ in part) / len(part), 4),
|
|
105
|
+
"actual": round(sum(y for _, y, _ in part) / len(part), 4), "n": len(part)})
|
|
106
|
+
by_rate = {}
|
|
107
|
+
for _, y, rate in scored:
|
|
108
|
+
n, k = by_rate.get(rate, (0, 0))
|
|
109
|
+
by_rate[rate] = (n + 1, k + y)
|
|
110
|
+
brier = sum((p - y) ** 2 for p, y, _ in scored) / len(scored)
|
|
111
|
+
baseline = sum((by_rate[rate][1] / by_rate[rate][0] - y) ** 2 for _, y, rate in scored) / len(scored)
|
|
112
|
+
return {"deciles": deciles, "brier": round(brier, 5), "baseline_brier": round(baseline, 5), "n": len(scored)}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def explain(model: dict, feats: dict, k: int = 3) -> list[dict]:
|
|
116
|
+
"""The biggest reasons behind a score, in plain words; reference buckets are never reasons."""
|
|
117
|
+
parts = [(f"{name}={value}", model["coef"].get(f"{name}={value}", 0.0)) for name, value in feats.items()]
|
|
118
|
+
parts = [(key, w) for key, w in parts if key in WORDS and abs(w) >= 0.1]
|
|
119
|
+
parts.sort(key=lambda kw: -abs(kw[1]))
|
|
120
|
+
return [{"why": WORDS[key], "effect": "raises" if w > 0 else "lowers", "weight": round(w, 3)} for key, w in parts[:k]]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
import duckdb
|
|
4
|
+
import pyarrow as pa
|
|
5
|
+
|
|
6
|
+
AI = r"\b(ai|llm|chatgpt|copilot)\b"
|
|
7
|
+
WALLS = {
|
|
8
|
+
"cla": r"contributor license agreement|\bcla\b|cla-assistant",
|
|
9
|
+
"dco": r"developer certificate of origin|signed-off-by|\bdco\b",
|
|
10
|
+
"issue_first": r"(open|file|create) an issue (first|before)"
|
|
11
|
+
r"|discuss(ed)? .{0,40}(first|before (opening|sending|submitting))",
|
|
12
|
+
"ai_ban": rf"(do not|don't|will not|won't|cannot|can't|not) (accept|allow|merge|review|take)[^.]{{0,40}}{AI}"
|
|
13
|
+
rf"|{AI}[- ](generated|written|assisted)[^.]{{0,60}}"
|
|
14
|
+
r"(not (be )?(accepted|allowed|merged|permitted|welcome)|prohibited|forbidden|banned|will be (closed|rejected))"
|
|
15
|
+
rf"|\b(prohibit(s|ed)?|bans?|banned|forbid(s|den)?)\b[^.]{{0,30}}{AI}",
|
|
16
|
+
# "...contributions that use other licenses" is a licence rule, not a ban on outside PRs.
|
|
17
|
+
"no_outside_prs": r"(not|no longer) accept(ing)? (external |outside |unsolicited )?(pull requests|contributions|prs)"
|
|
18
|
+
r"(?! (that|which|who|with|without|unless|under|using|licensed|from (?:bots|ai)))",
|
|
19
|
+
}
|
|
20
|
+
# AI rules and bans come from CONTRIBUTING only: a README describes the product, which may itself be about AI.
|
|
21
|
+
CONTRIBUTING_ONLY = {"ai_ban"}
|
|
22
|
+
AI_RULES = (r"\b(ai|llm)[- ](generated|assisted|assistance|guidelines|policy|usage|tools?|agents?|powered)\b"
|
|
23
|
+
r"|use of (ai|llm|generative)|generative ai|coding with ai")
|
|
24
|
+
# The Apache license header ("under one or more contributor license agreements") is not a CLA to sign.
|
|
25
|
+
LICENSE_HEADER = r"one or more\W+contributor license agreements?"
|
|
26
|
+
BLOCKING = set(WALLS) | {"issues_disabled"}
|
|
27
|
+
# "A lighter alternative, should the CLA prove heavy: the DCO" names a rule the repo does not have.
|
|
28
|
+
# Only words before the match count: "this is not optional" and "instead of sending a PR" follow real rules.
|
|
29
|
+
HYPOTHETICAL = r"\b(alternative|optionally|if you prefer|should (the|it|this|we) \S+ prove|we (might|may) (switch|adopt|move))\b"
|
|
30
|
+
# A list item, heading or blank line starts a new sentence even without a full stop.
|
|
31
|
+
BLOCK_BREAK = r"\n\s*\n|\n\s*(?=[-*+#>|]|\d+[.)]\s)"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _clean(text: str | None) -> str:
|
|
35
|
+
# One line of single spaces, so phrases broken across lines still match.
|
|
36
|
+
text = re.sub(BLOCK_BREAK, ". ", text or "")
|
|
37
|
+
return re.sub(LICENSE_HEADER, " ", " ".join(text.split()), flags=re.I)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _snippet(text: str, m: re.Match) -> str:
|
|
41
|
+
start = max(0, m.start() - 60)
|
|
42
|
+
return text[start:start + 160].strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sentence_before(text: str, m: re.Match) -> str:
|
|
46
|
+
starts = [b.end() for b in re.finditer(r"[.!?]\s", text[:m.start()])]
|
|
47
|
+
return text[starts[-1] if starts else 0:m.start()]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _first(wall: str, pattern: str, text: str) -> re.Match | None:
|
|
51
|
+
for m in re.finditer(pattern, text, re.I):
|
|
52
|
+
if re.search(HYPOTHETICAL, _sentence_before(text, m), re.I):
|
|
53
|
+
continue
|
|
54
|
+
# "For major changes, please open an issue first" (a common README template) does not stop small fixes.
|
|
55
|
+
if wall == "issue_first" and re.search(r"major change", text[max(0, m.start() - 40):m.start()], re.I):
|
|
56
|
+
continue
|
|
57
|
+
return m
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def find_walls(meta: dict) -> list[tuple[str, str]]:
|
|
62
|
+
found = []
|
|
63
|
+
if meta.get("has_issues") is False:
|
|
64
|
+
found.append(("issues_disabled", "issues are turned off for this repo"))
|
|
65
|
+
contributing = _clean(meta.get("contributing_excerpt"))
|
|
66
|
+
text = f"{contributing} {_clean(meta.get('readme_excerpt'))}"
|
|
67
|
+
for wall, pattern in WALLS.items():
|
|
68
|
+
source = contributing if wall in CONTRIBUTING_ONLY else text
|
|
69
|
+
m = _first(wall, pattern, source)
|
|
70
|
+
if m:
|
|
71
|
+
found.append((wall, _snippet(source, m)))
|
|
72
|
+
if "ai_ban" not in {w for w, _ in found}:
|
|
73
|
+
m = re.search(AI_RULES, contributing, re.I)
|
|
74
|
+
if m:
|
|
75
|
+
found.append(("ai_policy", _snippet(contributing, m)))
|
|
76
|
+
elif meta.get("ai_policy_sha"):
|
|
77
|
+
found.append(("ai_policy", "AI_POLICY.md in the repo root"))
|
|
78
|
+
return found
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_walls(con: duckdb.DuckDBPyConnection) -> None:
|
|
82
|
+
rows = []
|
|
83
|
+
for repo_id, has_issues, ai_sha, contributing, readme in con.execute(
|
|
84
|
+
"SELECT repo_id, has_issues, ai_policy_sha, contributing_excerpt, readme_excerpt FROM repo_meta").fetchall():
|
|
85
|
+
for wall, evidence in find_walls({"has_issues": has_issues, "ai_policy_sha": ai_sha,
|
|
86
|
+
"contributing_excerpt": contributing, "readme_excerpt": readme}):
|
|
87
|
+
rows.append({"repo_id": repo_id, "wall": wall, "blocking": wall in BLOCKING, "evidence": evidence})
|
|
88
|
+
schema = pa.schema([("repo_id", pa.int64()), ("wall", pa.string()), ("blocking", pa.bool_()),
|
|
89
|
+
("evidence", pa.string())])
|
|
90
|
+
con.register("walls_rows", pa.Table.from_pylist(rows, schema=schema))
|
|
91
|
+
con.execute("CREATE OR REPLACE TABLE repo_walls AS SELECT * FROM walls_rows")
|
|
File without changes
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from mergescout.config import load_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def write_extras(con, out: Path, now: datetime, log=lambda msg: print(msg, flush=True)) -> None:
|
|
12
|
+
"""Review-flow and community benchmarks, the triage model and the dependency evidence for the suite; a failure here
|
|
13
|
+
never stops the snapshot."""
|
|
14
|
+
import mergescout.signals.bump_ledger as bump_ledger
|
|
15
|
+
import mergescout.signals.flow_bench as flow_bench
|
|
16
|
+
import mergescout.signals.triage as triage
|
|
17
|
+
try:
|
|
18
|
+
bench = flow_bench.flow_benchmarks(con, now)
|
|
19
|
+
# Outside merge odds checked against what happened next, and coding-agent PRs per group, in the same file;
|
|
20
|
+
# their failure never costs the peer comparisons.
|
|
21
|
+
try:
|
|
22
|
+
bench["odds_backtest"] = flow_bench.odds_backtest(con, now)
|
|
23
|
+
bench["agent_prs"] = flow_bench.agent_prs(con, now)
|
|
24
|
+
except Exception as exc: # noqa: BLE001
|
|
25
|
+
log(f"odds backtest skipped: {exc}")
|
|
26
|
+
# Agents' PRs against people's of the same repo, month and size; older crawls without branch names skip it.
|
|
27
|
+
try:
|
|
28
|
+
bench["agent_matched"] = flow_bench.agent_matched(con, now)
|
|
29
|
+
except Exception as exc: # noqa: BLE001
|
|
30
|
+
log(f"matched agent gaps skipped: {exc}")
|
|
31
|
+
# Each coding agent on its own across the crawl: the public leaderboard repos compare their own agents with.
|
|
32
|
+
try:
|
|
33
|
+
bench["agent_leaderboard"] = flow_bench.agent_leaderboard(con, now)
|
|
34
|
+
except Exception as exc: # noqa: BLE001
|
|
35
|
+
log(f"agent leaderboard skipped: {exc}")
|
|
36
|
+
(out / "flow-benchmarks.json").write_text(json.dumps(bench, indent=1, sort_keys=True), encoding="utf-8")
|
|
37
|
+
log(f"flow benchmarks: {bench['repos']} repos, {len(bench['groups'])} groups")
|
|
38
|
+
bt = bench.get("odds_backtest") or {}
|
|
39
|
+
if bt.get("mae"):
|
|
40
|
+
log(f"outside merge odds backtest: {bt['repos']} repos, {bt['prs']} PRs; mean error raw {bt['mae']['raw']:.1%}, "
|
|
41
|
+
f"shrunk {bt['mae']['shrunk']:.1%}, typical only {bt['mae']['typical_only']:.1%}")
|
|
42
|
+
except Exception as exc: # noqa: BLE001
|
|
43
|
+
log(f"flow benchmarks skipped: {exc}")
|
|
44
|
+
try:
|
|
45
|
+
import mergescout.signals.delivery_bench as delivery_bench
|
|
46
|
+
bench = delivery_bench.delivery_benchmarks(con, now)
|
|
47
|
+
(out / "delivery-benchmarks.json").write_text(json.dumps(bench, indent=1, sort_keys=True), encoding="utf-8")
|
|
48
|
+
mid = (bench["groups"].get("all|all") or {}).get("pickup_p75_hours") or {}
|
|
49
|
+
log(f"delivery benchmarks: {bench['repos']} repos, {len(bench['groups'])} groups; pickup P75 median of all repos "
|
|
50
|
+
f"{mid.get('p50')} h ({mid.get('p50_low')}-{mid.get('p50_high')}), inside LinearB's good band "
|
|
51
|
+
f"{(mid.get('linearb') or {}).get('good')}")
|
|
52
|
+
except Exception as exc: # noqa: BLE001
|
|
53
|
+
log(f"delivery benchmarks skipped: {exc}")
|
|
54
|
+
try:
|
|
55
|
+
import mergescout.signals.community_bench as community_bench
|
|
56
|
+
bench = community_bench.community_benchmarks(con, now)
|
|
57
|
+
(out / "community-benchmarks.json").write_text(json.dumps(bench, indent=1, sort_keys=True), encoding="utf-8")
|
|
58
|
+
log(f"community benchmarks: {bench['repos']} repos, {len(bench['groups'])} groups")
|
|
59
|
+
# Logged daily so the newcomer lever is checked on the whole crawl, like the upgrade backtest.
|
|
60
|
+
log("newcomers returning by first answer: " + "; ".join(
|
|
61
|
+
f"{b['bucket']} {b['returned']:.1%} (n {b['newcomers']})" for b in bench["newcomers"]["by_answer"]))
|
|
62
|
+
except Exception as exc: # noqa: BLE001
|
|
63
|
+
log(f"community benchmarks skipped: {exc}")
|
|
64
|
+
try:
|
|
65
|
+
model = triage.build_triage_model(con, now)
|
|
66
|
+
(out / "triage-model.json").write_text(json.dumps(model, indent=1, sort_keys=True), encoding="utf-8")
|
|
67
|
+
log(f"triage model: {model['prs']} PRs, checked on {model['evaluation'].get('test_prs')}")
|
|
68
|
+
except Exception as exc: # noqa: BLE001
|
|
69
|
+
log(f"triage model skipped: {exc}")
|
|
70
|
+
try:
|
|
71
|
+
prs = bump_ledger.build_bump_prs(con)
|
|
72
|
+
cells = bump_ledger.build_bump_ledger(con, now)
|
|
73
|
+
(out / "deps-ledger.json").write_text(json.dumps(bump_ledger.ledger_json(con, now), sort_keys=True), encoding="utf-8")
|
|
74
|
+
log(f"upgrade ledger: {prs} bot PRs, {cells} cells")
|
|
75
|
+
except Exception as exc: # noqa: BLE001
|
|
76
|
+
log(f"upgrade ledger skipped: {exc}")
|
|
77
|
+
try:
|
|
78
|
+
# Whether flags on older upgrades predicted later trouble, re-checked daily as titles and CI results accumulate.
|
|
79
|
+
log(f"upgrade backtest: {bump_ledger.bump_backtest(con, now)}")
|
|
80
|
+
except Exception as exc: # noqa: BLE001
|
|
81
|
+
log(f"upgrade backtest skipped: {exc}")
|
|
82
|
+
try:
|
|
83
|
+
write_package_map(con, out, now, log)
|
|
84
|
+
except Exception as exc: # noqa: BLE001
|
|
85
|
+
log(f"package map skipped: {exc}")
|
|
86
|
+
try:
|
|
87
|
+
write_trends(con, out, now, log)
|
|
88
|
+
except Exception as exc: # noqa: BLE001
|
|
89
|
+
log(f"trend lines skipped: {exc}")
|
|
90
|
+
try:
|
|
91
|
+
write_vulns(out, now, log)
|
|
92
|
+
except Exception as exc: # noqa: BLE001
|
|
93
|
+
log(f"vulnerability history skipped: {exc}")
|
|
94
|
+
try:
|
|
95
|
+
write_owners(out, now, log)
|
|
96
|
+
except Exception as exc: # noqa: BLE001
|
|
97
|
+
log(f"ownership watch skipped: {exc}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def write_owners(out: Path, now: datetime, log) -> None:
|
|
101
|
+
"""Today's publishers and owners of the mapped packages, with the changes since yesterday's file (fetched by the workflow)."""
|
|
102
|
+
import mergescout.signals.owners_watch as ow
|
|
103
|
+
mapped = json.loads((out / "deps-map.json").read_text(encoding="utf-8")).get("packages", {})
|
|
104
|
+
packages = sorted((*key.split(":", 1), info.get("version")) for key, info in mapped.items())
|
|
105
|
+
path = out / "deps-owners.json"
|
|
106
|
+
previous = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
107
|
+
state = ow.refresh_owners(packages, previous, now)
|
|
108
|
+
path.write_text(json.dumps(state, sort_keys=True), encoding="utf-8")
|
|
109
|
+
changed = sum(1 for p in state["packages"].values() if p.get("events"))
|
|
110
|
+
log(f"ownership watch: {len(state['packages'])} packages, {changed} with changes since watching began")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def write_vulns(out: Path, now: datetime, log) -> None:
|
|
114
|
+
"""OSV history of the mapped packages; yesterday's file (fetched by the workflow) caches unchanged advisories."""
|
|
115
|
+
import mergescout.signals.osv as osv
|
|
116
|
+
from mergescout.signals.exploits import lookup
|
|
117
|
+
mapped = json.loads((out / "deps-map.json").read_text(encoding="utf-8")).get("packages", {})
|
|
118
|
+
packages = sorted(tuple(key.split(":", 1)) for key in mapped)
|
|
119
|
+
path = out / "deps-osv.json"
|
|
120
|
+
previous = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
121
|
+
# CISA KEV and FIRST's EPSS for the advisories' CVEs: one feed and about 80 requests a day, both free.
|
|
122
|
+
state = osv.refresh_vulns(packages, previous, now, exploits=lookup)
|
|
123
|
+
path.write_text(json.dumps(state, sort_keys=True), encoding="utf-8")
|
|
124
|
+
hit = sum(1 for p in state["packages"].values() if p.get("advisories"))
|
|
125
|
+
ex = state.get("exploits") or {}
|
|
126
|
+
log(f"vulnerability history: {len(state['packages'])} packages, {hit} with advisories, {len(state['records'])} advisories cached; "
|
|
127
|
+
f"{ex.get('scored', 0)} CVEs scored by EPSS, {ex.get('kev', 0)} in CISA KEV")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def write_trends(con, out: Path, now: datetime, log) -> None:
|
|
131
|
+
"""Quarterly lines for the watched source repos that the crawl already covers (the rest arrive as it crawls them)."""
|
|
132
|
+
from mergescout.signals.deps_trends import repo_trends
|
|
133
|
+
watch_file = out / "watch-repos.txt"
|
|
134
|
+
watch = watch_file.read_text(encoding="utf-8").split() if watch_file.exists() else []
|
|
135
|
+
trends = repo_trends(con, watch, now)
|
|
136
|
+
(out / "deps-trends.json").write_text(json.dumps({"built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "repos": trends},
|
|
137
|
+
sort_keys=True), encoding="utf-8")
|
|
138
|
+
log(f"trend lines: {len(trends)} of {len(watch)} watched repos are crawled")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def write_package_map(con, out: Path, now: datetime, log) -> None:
|
|
142
|
+
"""Source repos of the ledger's npm and PyPI packages and of the most-used ones; yesterday's file (fetched by the
|
|
143
|
+
workflow) is reused per package."""
|
|
144
|
+
import mergescout.signals.bump_ledger as bump_ledger
|
|
145
|
+
import mergescout.signals.deps_map as deps_map
|
|
146
|
+
import mergescout.signals.top_packages as top_packages
|
|
147
|
+
from mergescout.github.graphql import GitHubGraphQL
|
|
148
|
+
packages = con.execute("SELECT DISTINCT ecosystem, package FROM bump_ledger WHERE ecosystem IN ('npm', 'pypi') "
|
|
149
|
+
"AND prs >= $min", {"min": bump_ledger.MIN_PUBLISH}).fetchall()
|
|
150
|
+
path = out / "deps-map.json"
|
|
151
|
+
previous = json.loads(path.read_text(encoding="utf-8")).get("packages", {}) if path.exists() else {}
|
|
152
|
+
# A failed download keeps yesterday's top list; the ledger's packages come first within the lookup time budget.
|
|
153
|
+
top = top_packages.fetch_top() or [tuple(k.split(":", 1)) for k, v in previous.items() if v.get("top")]
|
|
154
|
+
# Claimed links are checked against manifests with the workflow's own token (about 1 point per 20 repos).
|
|
155
|
+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("MS_RELEASE_TOKEN")
|
|
156
|
+
gql = GitHubGraphQL(token, max_wait=0, reserve=50) if token else None
|
|
157
|
+
mapped = deps_map.refresh_map(list(dict.fromkeys([tuple(p) for p in packages] + top)), previous, now, gql=gql)
|
|
158
|
+
tops = set(top)
|
|
159
|
+
for key, info in mapped.items():
|
|
160
|
+
info["top"] = tuple(key.split(":", 1)) in tops
|
|
161
|
+
path.write_text(json.dumps({"built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "packages": mapped}, sort_keys=True),
|
|
162
|
+
encoding="utf-8")
|
|
163
|
+
links = {}
|
|
164
|
+
for info in mapped.values():
|
|
165
|
+
links[info.get("link")] = links.get(info.get("link"), 0) + 1
|
|
166
|
+
# Source repos the crawler should keep current (seed --watch); only links we can stand behind.
|
|
167
|
+
watch = sorted({i["repo"].lower() for i in mapped.values() if i.get("repo") and i.get("link") in ("verified", "claimed_checked")})
|
|
168
|
+
(out / "watch-repos.txt").write_text("".join(f"{r}\n" for r in watch), encoding="utf-8")
|
|
169
|
+
log(f"package map: {len(mapped)} packages ({len(tops)} most-used), links {links}, {len(watch)} repos to watch")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main(argv: list[str] | None = None) -> int:
|
|
173
|
+
parser = argparse.ArgumentParser(prog="python -m mergescout.snapshot", description="Build and publish the snapshot.")
|
|
174
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
175
|
+
b = sub.add_parser("build", help="Build signals and mergescout.sqlite from all crawl data")
|
|
176
|
+
b.add_argument("--from-releases", action="store_true")
|
|
177
|
+
b.add_argument("--out", default=None)
|
|
178
|
+
p = sub.add_parser("publish", help="Compress the snapshot and publish it as today's data release")
|
|
179
|
+
p.add_argument("dir")
|
|
180
|
+
p.add_argument("--keep", type=int, default=7)
|
|
181
|
+
p.add_argument("--force", action="store_true", help="Publish even if the snapshot shrank by more than half")
|
|
182
|
+
p.add_argument("--repo", default=os.environ.get("MS_STORE_REPO") or os.environ.get("GITHUB_REPOSITORY"))
|
|
183
|
+
args = parser.parse_args(argv)
|
|
184
|
+
cfg = load_config()
|
|
185
|
+
now = datetime.now(timezone.utc)
|
|
186
|
+
if args.cmd == "build":
|
|
187
|
+
from mergescout.signals.__main__ import prepare, write_bundle
|
|
188
|
+
from mergescout.snapshot.build import build_snapshot
|
|
189
|
+
out = Path(args.out) if args.out else cfg.data_dir / "snapshot"
|
|
190
|
+
con = prepare(cfg, out, args.from_releases, now)
|
|
191
|
+
if con is None:
|
|
192
|
+
return 1
|
|
193
|
+
write_bundle(con, out)
|
|
194
|
+
print(f"snapshot: {build_snapshot(con, out / 'mergescout.sqlite', now)}", flush=True)
|
|
195
|
+
write_extras(con, out, now)
|
|
196
|
+
con.close()
|
|
197
|
+
return 0
|
|
198
|
+
if args.cmd == "publish":
|
|
199
|
+
from mergescout.github.releases import Releases
|
|
200
|
+
from mergescout.snapshot.publish import publish
|
|
201
|
+
token = os.environ.get("MS_RELEASE_TOKEN")
|
|
202
|
+
if not token or not args.repo:
|
|
203
|
+
print("Set MS_RELEASE_TOKEN and MS_STORE_REPO (or GITHUB_REPOSITORY).", file=sys.stderr)
|
|
204
|
+
return 1
|
|
205
|
+
try:
|
|
206
|
+
publish(Releases(token, args.repo), Path(args.dir), now, keep=args.keep, force=args.force)
|
|
207
|
+
except ValueError as exc:
|
|
208
|
+
print(exc, file=sys.stderr)
|
|
209
|
+
return 1
|
|
210
|
+
return 0
|
|
211
|
+
return 2
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sqlite3
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import duckdb
|
|
7
|
+
|
|
8
|
+
from mergescout.signals.issues import norm_label_macro
|
|
9
|
+
from mergescout.snapshot.schema import DDL, SCHEMA_VERSION
|
|
10
|
+
from mergescout.snapshot.score import WEIGHTS, Weights
|
|
11
|
+
|
|
12
|
+
ISO = "CREATE OR REPLACE MACRO iso(x) AS strftime(timezone('UTC', x), '%Y-%m-%dT%H:%M:%SZ')"
|
|
13
|
+
# Issue-template checklist lines ("I affirm", "I have read the Contributing Guide") hide what the issue asks.
|
|
14
|
+
BOILERPLATE = (r"(?im)^.*\b(i affirm|i understand that if|i have (read|searched|checked|confirmed|verified)"
|
|
15
|
+
r"|code of conduct|this is not a duplicate)\b.*$\n?")
|
|
16
|
+
|
|
17
|
+
SCORED = """
|
|
18
|
+
CREATE OR REPLACE TABLE snap_issues AS
|
|
19
|
+
WITH lt AS (SELECT repo_id, label, CASE WHEN enough THEN outsider_low END AS fixed_low FROM label_trust),
|
|
20
|
+
il AS (SELECT repo_id, number, unnest(list_transform(labels, lambda x: norm_label(x))) AS label FROM issue_signals),
|
|
21
|
+
fixed AS (SELECT il.repo_id, il.number, max(lt.fixed_low) AS fixed_low FROM il JOIN lt USING (repo_id, label)
|
|
22
|
+
GROUP BY ALL),
|
|
23
|
+
walls AS (SELECT repo_id, count(*) FILTER (WHERE blocking) AS blocking FROM repo_walls GROUP BY repo_id),
|
|
24
|
+
base AS (
|
|
25
|
+
SELECT s.*, i.body_excerpt, rm.language, rs.merge_low, rs.review_enough, rs.median_review_hours, rs.newcomer_low,
|
|
26
|
+
coalesce(w.blocking, 0) AS blocking_walls, f.fixed_low
|
|
27
|
+
FROM issue_signals s JOIN issues i USING (repo_id, number) JOIN repo_meta rm USING (repo_id)
|
|
28
|
+
LEFT JOIN repo_stats rs USING (repo_id) LEFT JOIN walls w USING (repo_id) LEFT JOIN fixed f USING (repo_id, number)
|
|
29
|
+
WHERE s.opportunity OR s.available),
|
|
30
|
+
parts AS (
|
|
31
|
+
-- Every part is a DOUBLE: SQLite cannot store DuckDB decimals.
|
|
32
|
+
SELECT *, (CASE WHEN available THEN 1 ELSE 0 END)::DOUBLE AS p_avail,
|
|
33
|
+
coalesce(merge_low, $prior_merge)::DOUBLE AS p_merge,
|
|
34
|
+
(CASE WHEN review_enough THEN 1 / (1 + median_review_hours / $reply_half) ELSE $prior_reply END)::DOUBLE
|
|
35
|
+
AS p_reply,
|
|
36
|
+
coalesce(fixed_low, newcomer_low, $prior_fixed)::DOUBLE AS p_fixed,
|
|
37
|
+
(0.5 * least(length(coalesce(body_excerpt, '')), 600) / 600
|
|
38
|
+
+ 0.25 * CASE WHEN regexp_matches(lower(coalesce(body_excerpt, '')),
|
|
39
|
+
'(steps to reproduce|expected|```|error|traceback|repro)') THEN 1 ELSE 0 END
|
|
40
|
+
+ 0.25 * CASE WHEN len(coalesce(labels, [])) > 0 THEN 1 ELSE 0 END)::DOUBLE AS p_quality,
|
|
41
|
+
(greatest(power($dead_end, coalesce(dead_ends, 0)), $dead_end_floor)
|
|
42
|
+
* CASE WHEN coalesce(interest_30d, 0) >= $crowded_interest THEN $crowded ELSE 1 END
|
|
43
|
+
* CASE WHEN blocking_walls > 0 THEN $blocking ELSE 1 END
|
|
44
|
+
-- Trackers and debates are rarely a task; issues open for years usually hide a reason.
|
|
45
|
+
* CASE WHEN kind = 'discussion' THEN $discussion ELSE 1 END
|
|
46
|
+
* CASE WHEN triage_label THEN $untriaged ELSE 1 END
|
|
47
|
+
* CASE WHEN date_diff('day', created_at, $now) > $very_old_days THEN $very_old
|
|
48
|
+
WHEN date_diff('day', created_at, $now) > $old_days THEN $old ELSE 1 END)::DOUBLE AS mult
|
|
49
|
+
FROM base),
|
|
50
|
+
scored AS (
|
|
51
|
+
SELECT *, (CASE WHEN available THEN ($w_avail * p_avail + $w_merge * p_merge + $w_reply * p_reply
|
|
52
|
+
+ $w_fixed * p_fixed + $w_quality * p_quality) * mult ELSE 0 END)::DOUBLE
|
|
53
|
+
AS score
|
|
54
|
+
FROM parts),
|
|
55
|
+
ranked AS (
|
|
56
|
+
-- Freshness is applied once a day at build time, so queries only sort an index.
|
|
57
|
+
SELECT *, score * power(0.5, greatest(coalesce(date_diff('day', coalesce(updated_at, created_at), $now), 0), 0)
|
|
58
|
+
/ $fresh_half) AS rank_score,
|
|
59
|
+
row_number() OVER (PARTITION BY repo_id, opportunity ORDER BY score DESC, updated_at DESC) AS repo_rank
|
|
60
|
+
FROM scored)
|
|
61
|
+
SELECT * FROM ranked WHERE opportunity OR repo_rank <= $per_repo_other
|
|
62
|
+
ORDER BY opportunity DESC, rank_score DESC LIMIT $max_issues
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
ISSUE_ROWS = """
|
|
66
|
+
SELECT repo_id, number, title,
|
|
67
|
+
substr(ltrim(regexp_replace(coalesce(body_excerpt, ''), $boilerplate, '', 'g'), ' ' || chr(10) || chr(13) || chr(9)),
|
|
68
|
+
1, 300),
|
|
69
|
+
to_json(coalesce(labels, []))::VARCHAR,
|
|
70
|
+
language, iso(created_at), iso(updated_at), available::INT, opportunity::INT, blocked_by, assignees,
|
|
71
|
+
linked_open_prs, dead_ends, claimers, iso(last_claim_at), interest_30d, score, rank_score,
|
|
72
|
+
p_avail, p_merge, p_reply, p_fixed, p_quality, mult, iso(fetched_at), beginner::INT, kind
|
|
73
|
+
FROM snap_issues WHERE repo_id IN (SELECT repo_id FROM kept_repos)
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
REPO_ROWS = """
|
|
77
|
+
SELECT rm.repo_id, rm.name, rm.stars, rm.language, iso(rm.pushed_at), rm.archived::INT, rm.license,
|
|
78
|
+
rm.has_discussions::INT, rm.open_issues, rm.open_prs, coalesce(ins.n, 0), rs.outsider_prs, rs.merged, rs.closed,
|
|
79
|
+
rs.merge_rate, rs.merge_low, rs.merge_high, rs.merge_enough::INT, rs.newcomer_merged, rs.newcomer_decided,
|
|
80
|
+
rs.newcomer_rate, rs.newcomer_low, rs.newcomer_high, rs.newcomer_enough::INT, rs.reviewed, rs.median_review_hours,
|
|
81
|
+
rs.review_enough::INT, rs.window_days, rs.window_complete::INT, rs.ai_prs, rs.bot_prs, rs.insider_prs,
|
|
82
|
+
coalesce(w.blocking, 0), coalesce(w.walls_json, '[]'), coalesce(l.labels_json, '[]'), coalesce(n.notes, '[]'),
|
|
83
|
+
(coalesce(rm.contributing_excerpt, '') <> '')::INT, iso(rm.fetched_at)
|
|
84
|
+
FROM repo_meta rm LEFT JOIN repo_stats rs USING (repo_id)
|
|
85
|
+
LEFT JOIN (SELECT repo_id, count(*) AS n FROM people WHERE kind = 'insider' GROUP BY 1) ins USING (repo_id)
|
|
86
|
+
LEFT JOIN (SELECT repo_id, count(*) FILTER (WHERE blocking) AS blocking,
|
|
87
|
+
to_json(list({wall: wall, blocking: blocking, evidence: evidence} ORDER BY wall))::VARCHAR AS walls_json
|
|
88
|
+
FROM repo_walls GROUP BY 1) w USING (repo_id)
|
|
89
|
+
LEFT JOIN (SELECT repo_id, to_json(list({label: label, closed_n: closed_n, known_fixes: known_fixes,
|
|
90
|
+
outsider_fixes: outsider_fixes, outsider_share: outsider_share, low: outsider_low,
|
|
91
|
+
high: outsider_high, enough: enough, median_fix_lines: median_fix_lines,
|
|
92
|
+
median_fix_files: median_fix_files, open_stale: open_stale} ORDER BY label))::VARCHAR AS labels_json
|
|
93
|
+
FROM label_trust GROUP BY 1) l USING (repo_id)
|
|
94
|
+
-- Evidence notes are about the repo, so they are stored once per repo, not on every issue.
|
|
95
|
+
LEFT JOIN (SELECT repo_id, any_value(json_extract(evidence_json, '$.notes')::VARCHAR) AS notes
|
|
96
|
+
FROM candidates GROUP BY 1) n USING (repo_id)
|
|
97
|
+
WHERE rm.repo_id IN (SELECT repo_id FROM kept_repos)
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
EXAMPLE_ROWS = """
|
|
101
|
+
SELECT repo_id, number, iso(merged_at), additions + deletions, changed_files,
|
|
102
|
+
round(epoch(merged_at - created_at) / 86400, 1)
|
|
103
|
+
FROM (SELECT *, row_number() OVER (PARTITION BY repo_id ORDER BY merged_at DESC) AS rn FROM pr_class
|
|
104
|
+
WHERE author_class = 'outsider' AND state = 'MERGED' AND merged_at IS NOT NULL)
|
|
105
|
+
WHERE rn <= 3 AND repo_id IN (SELECT repo_id FROM kept_repos)
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_snapshot(con: duckdb.DuckDBPyConnection, path: Path, now: datetime, w: Weights = WEIGHTS,
|
|
110
|
+
per_repo_other: int = 30, max_issues: int = 350_000) -> dict:
|
|
111
|
+
norm_label_macro(con)
|
|
112
|
+
con.execute(ISO)
|
|
113
|
+
con.execute(SCORED, {
|
|
114
|
+
"prior_merge": w.prior_merge, "prior_reply": w.prior_reply, "prior_fixed": w.prior_fixed,
|
|
115
|
+
"reply_half": w.reply_half_hours, "dead_end": w.dead_end, "dead_end_floor": w.dead_end_floor,
|
|
116
|
+
"crowded_interest": w.crowded_interest, "crowded": w.crowded, "blocking": w.blocking_wall,
|
|
117
|
+
"w_avail": w.availability, "w_merge": w.outsider_merge, "w_reply": w.reply, "w_fixed": w.fixed_before,
|
|
118
|
+
"w_quality": w.issue_quality, "now": now, "fresh_half": w.freshness_half_days, "discussion": w.discussion, "untriaged": w.untriaged,
|
|
119
|
+
"old_days": w.old_days, "old": w.old, "very_old_days": w.very_old_days, "very_old": w.very_old,
|
|
120
|
+
"per_repo_other": per_repo_other, "max_issues": max_issues})
|
|
121
|
+
# A rename between crawls can leave two repo ids with one name; the newest copy wins.
|
|
122
|
+
con.execute("CREATE OR REPLACE TEMP TABLE kept_repos AS SELECT repo_id FROM repo_meta WHERE name IS NOT NULL "
|
|
123
|
+
"QUALIFY row_number() OVER (PARTITION BY lower(name) ORDER BY fetched_at DESC NULLS LAST, "
|
|
124
|
+
"repo_id DESC) = 1")
|
|
125
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
126
|
+
tmp.unlink(missing_ok=True)
|
|
127
|
+
db = sqlite3.connect(tmp)
|
|
128
|
+
db.executescript(DDL)
|
|
129
|
+
counts = {}
|
|
130
|
+
for table, sql in (("repos", REPO_ROWS), ("issues", ISSUE_ROWS), ("outsider_prs", EXAMPLE_ROWS)):
|
|
131
|
+
rows = con.execute(sql, {"boilerplate": BOILERPLATE} if table == "issues" else None).fetchall()
|
|
132
|
+
if rows:
|
|
133
|
+
db.executemany(f"INSERT INTO {table} VALUES ({', '.join('?' * len(rows[0]))})", rows)
|
|
134
|
+
counts[table] = len(rows)
|
|
135
|
+
db.execute("INSERT INTO issues_fts (rowid, title, labels, body) SELECT rowid, title, labels, body FROM issues")
|
|
136
|
+
meta = {"schema_version": SCHEMA_VERSION, "built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
137
|
+
"issues": counts["issues"], "repos": counts["repos"],
|
|
138
|
+
"free_issues": db.execute("SELECT count(*) FROM issues WHERE available = 1").fetchone()[0]}
|
|
139
|
+
db.executemany("INSERT INTO meta VALUES (?, ?)", [(k, str(v)) for k, v in meta.items()])
|
|
140
|
+
db.commit()
|
|
141
|
+
db.execute("INSERT INTO issues_fts (issues_fts) VALUES ('optimize')")
|
|
142
|
+
db.execute("ANALYZE")
|
|
143
|
+
db.commit()
|
|
144
|
+
db.execute("VACUUM")
|
|
145
|
+
db.close()
|
|
146
|
+
os.replace(tmp, path)
|
|
147
|
+
return counts
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import sqlite3
|
|
4
|
+
import tempfile
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import zstandard
|
|
9
|
+
|
|
10
|
+
SNAPSHOT, PACKED, MANIFEST, PREFIX = "mergescout.sqlite", "mergescout.sqlite.zst", "manifest.json", "data-"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sha256(path: Path) -> str:
|
|
14
|
+
h = hashlib.sha256()
|
|
15
|
+
with path.open("rb") as f:
|
|
16
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
17
|
+
h.update(chunk)
|
|
18
|
+
return h.hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def pack(out: Path) -> dict:
|
|
22
|
+
src = out / SNAPSHOT
|
|
23
|
+
db = sqlite3.connect(src)
|
|
24
|
+
meta = dict(db.execute("SELECT key, value FROM meta").fetchall())
|
|
25
|
+
db.close()
|
|
26
|
+
with src.open("rb") as fi, (out / PACKED).open("wb") as fo:
|
|
27
|
+
# One thread and the known size keep memory small (threads ran out of memory on a 8 GB laptop; same speed).
|
|
28
|
+
zstandard.ZstdCompressor(level=19).copy_stream(fi, fo, size=src.stat().st_size)
|
|
29
|
+
manifest = {"schema_version": meta["schema_version"], "built_at": meta["built_at"], "issues": int(meta["issues"]),
|
|
30
|
+
"repos": int(meta["repos"]), "sha256": sha256(out / PACKED), "size": (out / PACKED).stat().st_size,
|
|
31
|
+
"sqlite_size": src.stat().st_size}
|
|
32
|
+
(out / MANIFEST).write_text(json.dumps(manifest, indent=1), encoding="utf-8")
|
|
33
|
+
return manifest
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def previous_manifest(releases, tag: str) -> dict | None:
|
|
37
|
+
# The newest published day other than the one being written.
|
|
38
|
+
older = sorted((r for r in releases.list_releases() if r["tag_name"].startswith(PREFIX) and r["tag_name"] != tag),
|
|
39
|
+
key=lambda r: r["tag_name"], reverse=True)
|
|
40
|
+
for release in older:
|
|
41
|
+
asset = next((a for a in releases.assets(release) if a["name"] == MANIFEST), None)
|
|
42
|
+
if asset:
|
|
43
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
44
|
+
path = Path(tmp) / MANIFEST
|
|
45
|
+
releases.download_asset(asset, path)
|
|
46
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def publish(releases, out: Path, now: datetime, keep: int = 7, force: bool = False, min_ratio: float = 0.5,
|
|
51
|
+
log=print) -> str:
|
|
52
|
+
manifest = pack(out)
|
|
53
|
+
if not manifest["issues"] or not manifest["repos"]:
|
|
54
|
+
raise ValueError("refusing to publish an empty snapshot")
|
|
55
|
+
tag = f"{PREFIX}{now:%Y-%m-%d}"
|
|
56
|
+
prev = previous_manifest(releases, tag)
|
|
57
|
+
# A build that lost part of the crawl data must not replace a good day; --force when the drop is expected.
|
|
58
|
+
if prev and not force and manifest["issues"] < min_ratio * (prev.get("issues") or 0):
|
|
59
|
+
raise ValueError(f"refusing to publish: the snapshot shrank from {prev['issues']} to {manifest['issues']} "
|
|
60
|
+
"issues (a partial build?); use --force if this is expected")
|
|
61
|
+
# Data first, manifest last: a reader never sees a manifest whose file is missing (the checksum guards the rest).
|
|
62
|
+
releases.upload(tag, out / PACKED)
|
|
63
|
+
releases.upload(tag, out / MANIFEST)
|
|
64
|
+
data = sorted((r for r in releases.list_releases() if r["tag_name"].startswith(PREFIX)),
|
|
65
|
+
key=lambda r: r["tag_name"], reverse=True)
|
|
66
|
+
for old in data[keep:]:
|
|
67
|
+
releases.delete_release(old)
|
|
68
|
+
log(f"removed {old['tag_name']}")
|
|
69
|
+
log(f"published {tag}: {manifest['issues']} issues, {manifest['size'] / 1e6:.1f} MB")
|
|
70
|
+
return tag
|