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,232 @@
|
|
|
1
|
+
"""Vulnerability history per package from OSV (free, no key): every advisory ever, merged across aliases (GHSA, PYSEC,
|
|
2
|
+
CVE are often the same flaw), with the ones still unfixed and any malware record. Records are cached by their modified
|
|
3
|
+
time, so a daily run fetches only new or changed ones."""
|
|
4
|
+
import re
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from mergescout.signals.bumps import _norm
|
|
10
|
+
from mergescout.signals.deps_map import http_get_json, http_post_json
|
|
11
|
+
|
|
12
|
+
API = "https://api.osv.dev"
|
|
13
|
+
ECOSYSTEMS = {"pypi": "PyPI", "npm": "npm"}
|
|
14
|
+
QUERY_BATCH = 1000
|
|
15
|
+
# A first run would fetch every advisory at once (about 2,300 for 300 packages took 3 minutes); later runs only new ones.
|
|
16
|
+
MAX_FETCH = 4000
|
|
17
|
+
RANK = {"CRITICAL": 4, "HIGH": 3, "MODERATE": 2, "MEDIUM": 2, "LOW": 1}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ids(packages: list[tuple[str, str]], post_json) -> dict[str, dict[str, str]]:
|
|
21
|
+
# Advisory ids and their modified time per package; a package with many advisories answers in pages.
|
|
22
|
+
out = {}
|
|
23
|
+
wanted = [(eco, name) for eco, name in packages if eco in ECOSYSTEMS]
|
|
24
|
+
for i in range(0, len(wanted), QUERY_BATCH):
|
|
25
|
+
chunk = wanted[i:i + QUERY_BATCH]
|
|
26
|
+
queries = [{"package": {"name": name, "ecosystem": ECOSYSTEMS[eco]}} for eco, name in chunk]
|
|
27
|
+
results = post_json(f"{API}/v1/querybatch", {"queries": queries}).get("results") or []
|
|
28
|
+
for (eco, name), query, result in zip(chunk, queries, results):
|
|
29
|
+
found = {v["id"]: v.get("modified") for v in (result or {}).get("vulns") or []}
|
|
30
|
+
token = (result or {}).get("next_page_token")
|
|
31
|
+
while token:
|
|
32
|
+
more = (post_json(f"{API}/v1/querybatch", {"queries": [{**query, "page_token": token}]}).get("results") or [{}])[0]
|
|
33
|
+
found.update({v["id"]: v.get("modified") for v in (more or {}).get("vulns") or []})
|
|
34
|
+
token = (more or {}).get("next_page_token")
|
|
35
|
+
out[f"{eco}:{name}"] = found
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _facts(record: dict, keys: set[str]) -> dict:
|
|
40
|
+
# The few fields the history needs; the advisory text is not kept.
|
|
41
|
+
pkgs, bounds, bad, spans = {}, {}, {}, {}
|
|
42
|
+
mal = _is_malware(record)
|
|
43
|
+
for a in record.get("affected") or []:
|
|
44
|
+
pkg = a.get("package") or {}
|
|
45
|
+
eco = next((k for k, v in ECOSYSTEMS.items() if v == pkg.get("ecosystem")), None)
|
|
46
|
+
key = f"{eco}:{_norm(eco, pkg.get('name') or '')}" if eco else None
|
|
47
|
+
if key in keys:
|
|
48
|
+
events = [e for r in a.get("ranges") or [] for e in r.get("events") or []]
|
|
49
|
+
pkgs[key] = pkgs.get(key, False) or any("fixed" in e for e in events)
|
|
50
|
+
# "last_affected" bounds the range without naming a fix: newer releases are not affected.
|
|
51
|
+
bounds[key] = bounds.get(key, []) + [e["last_affected"] for e in events if e.get("last_affected")]
|
|
52
|
+
# Each [introduced, fixed) span, so a PR's version can be checked and the fixing version named.
|
|
53
|
+
for r in a.get("ranges") or []:
|
|
54
|
+
if r.get("type") in ("ECOSYSTEM", "SEMVER"):
|
|
55
|
+
start = None
|
|
56
|
+
for e in r.get("events") or []:
|
|
57
|
+
if "introduced" in e:
|
|
58
|
+
start = e["introduced"]
|
|
59
|
+
elif "fixed" in e and start is not None:
|
|
60
|
+
spans.setdefault(key, []).append([start, e["fixed"]])
|
|
61
|
+
start = None
|
|
62
|
+
elif "last_affected" in e and start is not None:
|
|
63
|
+
# Up to and including that version, with no fix named.
|
|
64
|
+
spans.setdefault(key, []).append([start, e["last_affected"], True])
|
|
65
|
+
start = None
|
|
66
|
+
if start is not None:
|
|
67
|
+
spans.setdefault(key, []).append([start, None])
|
|
68
|
+
if mal:
|
|
69
|
+
# A malware record lists the bad versions; a range open from "0" means every version (OSV's rule).
|
|
70
|
+
every = any(e.get("introduced") == "0" for e in events) and not any("fixed" in e or "last_affected" in e
|
|
71
|
+
for e in events)
|
|
72
|
+
bad[key] = ["all"] if every or "all" in bad.get(key, []) else \
|
|
73
|
+
sorted(set(bad.get(key, [])) | set(a.get("versions") or []), key=_version_key)[-50:]
|
|
74
|
+
# OSV keeps CVEs the supplier disputes and says so in the text; only the flag is kept.
|
|
75
|
+
text = f"{record.get('summary') or ''} {record.get('details') or ''}".lower()
|
|
76
|
+
return {"rv": RECORD, "id": record.get("id"), "aliases": record.get("aliases") or [], "published": record.get("published"),
|
|
77
|
+
"modified": record.get("modified"), "withdrawn": bool(record.get("withdrawn")),
|
|
78
|
+
"severity": ((record.get("database_specific") or {}).get("severity") or "").upper() or None, "pkgs": pkgs,
|
|
79
|
+
"bounds": bounds, "ranges": spans, "disputed": "disputed" in text, **({"bad": bad} if mal else {})}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_malware(record: dict) -> bool:
|
|
83
|
+
return any(n.startswith("MAL-") for n in (record.get("id") or "", *(record.get("aliases") or [])))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# The record format: a record built by older code is read again (last_affected spans came in version 2).
|
|
87
|
+
RECORD = 2
|
|
88
|
+
# Pre-releases sort before their release and post-releases after it (PEP 440 and semver agree on that much).
|
|
89
|
+
PHASES = {"dev": 0, "a": 1, "alpha": 1, "b": 2, "beta": 2, "c": 3, "rc": 3, "pre": 3, "preview": 3, "post": 5}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def version_key(v: str | None) -> tuple:
|
|
93
|
+
"""(epoch, release padded to 4, phase, number): 2.0.0rc1 < 2.0.0 < 2.0.0.post1, 1!0.1 > 9.9."""
|
|
94
|
+
s = (v or "").strip().lower().removeprefix("v")
|
|
95
|
+
epoch = 0
|
|
96
|
+
if "!" in s:
|
|
97
|
+
head, s = s.split("!", 1)
|
|
98
|
+
epoch = int(head) if head.isdigit() else 0
|
|
99
|
+
m = re.match(r"(\d+(?:\.\d+)*)(.*)", s)
|
|
100
|
+
if not m:
|
|
101
|
+
return (epoch, (0, 0, 0, 0), 4, 0)
|
|
102
|
+
release = tuple(int(x) for x in m.group(1).split(".")[:4])
|
|
103
|
+
release += (0,) * (4 - len(release))
|
|
104
|
+
rest = m.group(2).split("+", 1)[0]
|
|
105
|
+
tag = re.search(r"(dev|alpha|beta|preview|pre|post|rc|a|b|c)[.-]?(\d*)", rest)
|
|
106
|
+
if tag:
|
|
107
|
+
return (epoch, release, PHASES[tag.group(1)], int(tag.group(2) or 0))
|
|
108
|
+
# Any other suffix after a dash (1.0.0-next.3) is a semver pre-release.
|
|
109
|
+
return (epoch, release, 3 if rest.startswith("-") else 4, 0)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _version_key(v: str) -> tuple:
|
|
113
|
+
return version_key(v)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _cves(group: list[dict]) -> list[str]:
|
|
117
|
+
return sorted({n for x in group for n in (x["id"], *x["aliases"]) if n.startswith("CVE-")})
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _summary(key: str, facts: list[dict], now: datetime, exploits: dict | None = None) -> dict:
|
|
121
|
+
live = [f for f in facts if not f["withdrawn"] and key in f["pkgs"]]
|
|
122
|
+
# One flaw under several ids (aliases) counts once: group ids that share any id or alias.
|
|
123
|
+
groups, seen = [], {}
|
|
124
|
+
for f in live:
|
|
125
|
+
names = {f["id"], *f["aliases"]}
|
|
126
|
+
hit = {seen[n] for n in names if n in seen}
|
|
127
|
+
merged = [f]
|
|
128
|
+
for g in sorted(hit, reverse=True):
|
|
129
|
+
merged += groups.pop(g)
|
|
130
|
+
groups.append(merged)
|
|
131
|
+
seen = {n: i for i, g in enumerate(groups) for x in g for n in (x["id"], *x["aliases"])}
|
|
132
|
+
since = (now - timedelta(days=730)).strftime("%Y-%m-%d")
|
|
133
|
+
recent = (now - timedelta(days=90)).strftime("%Y-%m-%d")
|
|
134
|
+
severity, out = {}, {"advisories": len(groups), "last_2y": 0, "last_90d": 0, "unfixed": 0, "latest": None, "ids": []}
|
|
135
|
+
malware, last_affected, bad = set(), set(), set()
|
|
136
|
+
out["disputed"] = 0
|
|
137
|
+
kev, epss_open, epss_max = [], None, None
|
|
138
|
+
affects = []
|
|
139
|
+
open_label = None
|
|
140
|
+
for g in groups:
|
|
141
|
+
first = min((x["published"] or "")[:10] for x in g)
|
|
142
|
+
out["last_2y"] += first >= since
|
|
143
|
+
out["last_90d"] += first >= recent
|
|
144
|
+
# A malware record lists bad versions and never has a fix; it is reported as malware, not as unfixed.
|
|
145
|
+
mal = {n for x in g for n in (x["id"], *x["aliases"]) if n.startswith("MAL-")}
|
|
146
|
+
malware |= mal
|
|
147
|
+
if mal:
|
|
148
|
+
bad |= {v for x in g for v in (x.get("bad") or {}).get(key, [])}
|
|
149
|
+
fixed = any(x["pkgs"][key] for x in g)
|
|
150
|
+
bound = [v for x in g for v in (x.get("bounds") or {}).get(key, [])]
|
|
151
|
+
if mal or fixed:
|
|
152
|
+
pass
|
|
153
|
+
elif any(x.get("disputed") for x in g):
|
|
154
|
+
out["disputed"] += 1
|
|
155
|
+
elif bound:
|
|
156
|
+
# Open only while the newest release is inside the bound; the dossier compares with the current version.
|
|
157
|
+
last_affected.add(max(bound, key=_version_key))
|
|
158
|
+
else:
|
|
159
|
+
out["unfixed"] += 1
|
|
160
|
+
# Exploited in the wild (KEV), and the chance of it (EPSS): an open advisory's matters most.
|
|
161
|
+
for cve in _cves(g) if exploits is not None else ():
|
|
162
|
+
e = exploits.get(cve) or {}
|
|
163
|
+
if e.get("kev"):
|
|
164
|
+
kev.append({"id": cve, "fixed": bool(fixed)})
|
|
165
|
+
if "epss" in e:
|
|
166
|
+
score = {"id": cve, "score": e["epss"], "percentile": e.get("percentile")}
|
|
167
|
+
epss_max = max(epss_max or score, score, key=lambda s: s["score"])
|
|
168
|
+
if not (mal or fixed or bound):
|
|
169
|
+
epss_open = max(epss_open or score, score, key=lambda s: s["score"])
|
|
170
|
+
spans = [s for x in g for s in (x.get("ranges") or {}).get(key, [])]
|
|
171
|
+
label = max((x["severity"] for x in g if x["severity"]), key=lambda s: RANK.get(s, 0), default=None)
|
|
172
|
+
disputed = any(x.get("disputed") for x in g)
|
|
173
|
+
if spans and not mal:
|
|
174
|
+
affects.append({"id": g[0]["id"], "ranges": spans, "severity": label, "disputed": disputed})
|
|
175
|
+
out["latest"] = max(out["latest"] or "", first) or None
|
|
176
|
+
if label:
|
|
177
|
+
severity[label] = severity.get(label, 0) + 1
|
|
178
|
+
# The worst advisory still open (no fix, or a range ending at a bound), for a repo's severity policy.
|
|
179
|
+
if not (mal or fixed or bound or disputed) and RANK.get(label, 0) > RANK.get(open_label or "", 0):
|
|
180
|
+
open_label = label
|
|
181
|
+
out["severity"], out["open_severity"] = severity, open_label
|
|
182
|
+
out["affects"] = affects
|
|
183
|
+
out["malware"], out["malware_ids"] = bool(malware), sorted(malware)
|
|
184
|
+
out["malware_versions"] = ["all"] if "all" in bad else sorted(bad, key=_version_key)[-50:]
|
|
185
|
+
out["last_affected"] = sorted(last_affected, key=_version_key)
|
|
186
|
+
out["ids"] = [g[0]["id"] for g in sorted(groups, key=lambda g: min(x["published"] or "" for x in g), reverse=True)[:5]]
|
|
187
|
+
if exploits is not None:
|
|
188
|
+
out["kev"] = sorted({k["id"]: k for k in kev}.values(), key=lambda k: k["id"])
|
|
189
|
+
out["epss_open"], out["epss_max"] = epss_open, epss_max
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _fetch(get_json, advisory_id: str) -> dict | None:
|
|
194
|
+
# A record lost after retries is left out of the cache, so the next run asks for it again.
|
|
195
|
+
try:
|
|
196
|
+
return get_json(f"{API}/v1/vulns/{quote(advisory_id, safe='')}")
|
|
197
|
+
except Exception: # noqa: BLE001
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def refresh_vulns(packages: list[tuple[str, str]], previous: dict, now: datetime, post_json=http_post_json,
|
|
202
|
+
get_json=http_get_json, workers: int = 8, max_fetch: int = MAX_FETCH, exploits=None) -> dict:
|
|
203
|
+
"""{"records": cached facts by advisory id, "packages": history per "ecosystem:name"}; only new or changed
|
|
204
|
+
advisories are fetched again. exploits: a lookup cves -> {cve: {"epss", "percentile", "kev"}} (signals.exploits);
|
|
205
|
+
without one, no exploit evidence is added."""
|
|
206
|
+
keys = {f"{eco}:{name}" for eco, name in packages if eco in ECOSYSTEMS}
|
|
207
|
+
ids = _ids(packages, post_json)
|
|
208
|
+
old = previous.get("records") or {}
|
|
209
|
+
# Records cached before ranges' bounds (or malware versions) were kept are read once more.
|
|
210
|
+
stale = lambda r: r.get("rv") != RECORD or (_is_malware(r) and "bad" not in r) # noqa: E731
|
|
211
|
+
todo = sorted({i for found in ids.values() for i, mod in found.items()
|
|
212
|
+
if (old.get(i) or {}).get("modified") != mod or stale(old.get(i) or {})})
|
|
213
|
+
complete = len(todo) <= max_fetch
|
|
214
|
+
todo = todo[:max_fetch]
|
|
215
|
+
with ThreadPoolExecutor(workers) as pool:
|
|
216
|
+
fetched = list(pool.map(lambda i: _fetch(get_json, i), todo))
|
|
217
|
+
records = {i: old[i] for found in ids.values() for i in found if i in old and i not in todo}
|
|
218
|
+
# The cache keeps the batch answer's modified time: the record itself may spell it with other precision.
|
|
219
|
+
modified = {i: mod for found in ids.values() for i, mod in found.items()}
|
|
220
|
+
records.update({i: {**_facts(r, keys), "modified": modified[i]} for i, r in zip(todo, fetched) if r})
|
|
221
|
+
found = None
|
|
222
|
+
if exploits is not None:
|
|
223
|
+
cves = sorted({n for r in records.values() for n in (r["id"], *r["aliases"]) if n.startswith("CVE-")})
|
|
224
|
+
found = exploits(cves) or {}
|
|
225
|
+
packages_out = {key: _summary(key, [records[i] for i in ids.get(key, {}) if i in records], now, found)
|
|
226
|
+
for key in sorted(keys)}
|
|
227
|
+
# Not complete: some advisories wait for the next run, so counts may still grow.
|
|
228
|
+
out = {"built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "complete": complete, "records": records, "packages": packages_out}
|
|
229
|
+
if found is not None:
|
|
230
|
+
out["exploits"] = {"cves": len(cves), "scored": sum("epss" in e for e in found.values()),
|
|
231
|
+
"kev": sum(bool(e.get("kev")) for e in found.values())}
|
|
232
|
+
return out
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Who can publish each package, watched daily: npm maintainers and the publisher of the latest release (and whether it came
|
|
2
|
+
through trusted CI publishing), PyPI owners and organization. Registries keep no history of this; the changes are the
|
|
3
|
+
signal (a takeover usually starts with a new maintainer or a release published outside the usual CI). Emails are never kept."""
|
|
4
|
+
import time
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from mergescout.signals.deps_map import http_get_json
|
|
10
|
+
|
|
11
|
+
MAX_EVENTS = 20
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _snapshot(eco: str, name: str, version: str | None, get_json) -> dict | None:
|
|
15
|
+
if eco == "npm":
|
|
16
|
+
d = get_json(f"https://registry.npmjs.org/{quote(name, safe='@')}/latest")
|
|
17
|
+
if not d:
|
|
18
|
+
return None
|
|
19
|
+
user = d.get("_npmUser") or {}
|
|
20
|
+
trusted = bool(user.get("trustedPublisher") or (d.get("dist") or {}).get("attestations"))
|
|
21
|
+
return {"version": d.get("version"), "publisher": user.get("name"), "trusted": trusted,
|
|
22
|
+
"maintainers": sorted(m.get("name") for m in d.get("maintainers") or [] if m.get("name"))}
|
|
23
|
+
if eco == "pypi" and version:
|
|
24
|
+
d = get_json(f"https://pypi.org/pypi/{quote(name, safe='')}/{quote(version, safe='')}/json")
|
|
25
|
+
if not d:
|
|
26
|
+
return None
|
|
27
|
+
own = d.get("ownership") or {}
|
|
28
|
+
return {"version": version, "organization": own.get("organization"),
|
|
29
|
+
"maintainers": sorted(r.get("user") for r in own.get("roles") or [] if r.get("user"))}
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _events(old: dict, new: dict, day: str) -> list[dict]:
|
|
34
|
+
out = []
|
|
35
|
+
added = sorted(set(new.get("maintainers") or []) - set(old.get("maintainers") or []))
|
|
36
|
+
removed = sorted(set(old.get("maintainers") or []) - set(new.get("maintainers") or []))
|
|
37
|
+
if added:
|
|
38
|
+
out.append({"kind": "maintainer_added", "date": day, "who": added})
|
|
39
|
+
if removed:
|
|
40
|
+
out.append({"kind": "maintainer_removed", "date": day, "who": removed})
|
|
41
|
+
if old.get("trusted") and new.get("trusted") is False:
|
|
42
|
+
out.append({"kind": "trusted_lost", "date": day, "version": new.get("version")})
|
|
43
|
+
if old.get("publisher") and new.get("publisher") and old["publisher"] != new["publisher"]:
|
|
44
|
+
out.append({"kind": "publisher_changed", "date": day, "from": old["publisher"], "to": new["publisher"]})
|
|
45
|
+
if old.get("organization") != new.get("organization") and ("organization" in old or "organization" in new):
|
|
46
|
+
out.append({"kind": "organization_changed", "date": day, "from": old.get("organization"), "to": new.get("organization")})
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def refresh_owners(packages: list[tuple[str, str, str | None]], previous: dict, now: datetime, get_json=http_get_json,
|
|
51
|
+
workers: int = 16, budget_s: float = 900) -> dict:
|
|
52
|
+
"""Today's snapshot per package and the dated changes since watching began; a package not read today (an error or
|
|
53
|
+
the time budget) keeps yesterday's entry and gets no event."""
|
|
54
|
+
day = now.strftime("%Y-%m-%d")
|
|
55
|
+
deadline = time.time() + budget_s
|
|
56
|
+
old = previous.get("packages") or {}
|
|
57
|
+
|
|
58
|
+
def read(item):
|
|
59
|
+
eco, name, version = item
|
|
60
|
+
if time.time() > deadline:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return _snapshot(eco, name, version, get_json)
|
|
64
|
+
except Exception: # noqa: BLE001
|
|
65
|
+
return None
|
|
66
|
+
with ThreadPoolExecutor(workers) as pool:
|
|
67
|
+
snaps = list(pool.map(read, packages))
|
|
68
|
+
out = {}
|
|
69
|
+
for (eco, name, _), snap in zip(packages, snaps):
|
|
70
|
+
key, before = f"{eco}:{name}", old.get(f"{eco}:{name}")
|
|
71
|
+
if snap is None:
|
|
72
|
+
if before:
|
|
73
|
+
out[key] = before
|
|
74
|
+
continue
|
|
75
|
+
if before:
|
|
76
|
+
events = (before.get("events") or []) + _events(before.get("current") or {}, snap, day)
|
|
77
|
+
out[key] = {"current": snap, "since": before.get("since", day), "events": events[-MAX_EVENTS:]}
|
|
78
|
+
else:
|
|
79
|
+
out[key] = {"current": snap, "since": day, "events": []}
|
|
80
|
+
return {"built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "packages": out}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
|
|
4
|
+
import duckdb
|
|
5
|
+
|
|
6
|
+
from mergescout.signals.rules import RULES, Rules
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float] | None:
|
|
10
|
+
if n == 0:
|
|
11
|
+
return None
|
|
12
|
+
p = k / n
|
|
13
|
+
centre = p + z * z / (2 * n)
|
|
14
|
+
margin = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
|
|
15
|
+
denom = 1 + z * z / n
|
|
16
|
+
return (centre - margin) / denom, min(1.0, (centre + margin) / denom)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _wilson_macros(con: duckdb.DuckDBPyConnection) -> None:
|
|
20
|
+
con.execute("""
|
|
21
|
+
CREATE OR REPLACE MACRO wilson_lo(k, n) AS CASE WHEN n = 0 THEN NULL ELSE
|
|
22
|
+
((k::DOUBLE / n + 1.9208 / n) - 1.96 * sqrt((k::DOUBLE / n) * (1 - k::DOUBLE / n) / n + 0.9604 / (n * n)))
|
|
23
|
+
/ (1 + 3.8416 / n) END;
|
|
24
|
+
CREATE OR REPLACE MACRO wilson_hi(k, n) AS CASE WHEN n = 0 THEN NULL ELSE least(1.0,
|
|
25
|
+
((k::DOUBLE / n + 1.9208 / n) + 1.96 * sqrt((k::DOUBLE / n) * (1 - k::DOUBLE / n) / n + 0.9604 / (n * n)))
|
|
26
|
+
/ (1 + 3.8416 / n)) END;""")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_repo_stats(con: duckdb.DuckDBPyConnection, now: datetime, rules: Rules = RULES) -> None:
|
|
30
|
+
_wilson_macros(con)
|
|
31
|
+
since = now - timedelta(days=rules.window_days)
|
|
32
|
+
con.execute("""
|
|
33
|
+
CREATE OR REPLACE TABLE repo_stats AS
|
|
34
|
+
WITH ranked AS (
|
|
35
|
+
SELECT repo_id, created_at, row_number() OVER (PARTITION BY repo_id ORDER BY created_at DESC) AS rn
|
|
36
|
+
FROM prs WHERE created_at >= $since),
|
|
37
|
+
-- Full crawls keep the newest PRs up to the cap; refreshes can add older ones, which must not widen the window.
|
|
38
|
+
span AS (
|
|
39
|
+
SELECT p.repo_id, coalesce(c.seen, 0) >= $cap AS truncated,
|
|
40
|
+
CASE WHEN coalesce(c.seen, 0) >= $cap THEN c.cap_start ELSE $since END AS window_start
|
|
41
|
+
FROM (SELECT DISTINCT repo_id FROM prs) p
|
|
42
|
+
LEFT JOIN (SELECT repo_id, count(*) AS seen, min(created_at) FILTER (WHERE rn <= $cap) AS cap_start
|
|
43
|
+
FROM ranked GROUP BY repo_id) c USING (repo_id)),
|
|
44
|
+
-- Prefer the first person's review; older batches only know the very first review.
|
|
45
|
+
w AS (SELECT * REPLACE (coalesce(first_human_review_at, first_review_at) AS first_review_at,
|
|
46
|
+
coalesce(first_human_review_by, first_review_by) AS first_review_by)
|
|
47
|
+
FROM pr_class JOIN span USING (repo_id) WHERE created_at >= window_start),
|
|
48
|
+
firsts AS (
|
|
49
|
+
SELECT repo_id, author_login, min(merged_at) AS first_merge FROM pr_class
|
|
50
|
+
WHERE author_class = 'outsider' AND merged_at IS NOT NULL GROUP BY ALL),
|
|
51
|
+
o AS (
|
|
52
|
+
SELECT w.repo_id,
|
|
53
|
+
count(*) AS outsider_prs,
|
|
54
|
+
count(*) FILTER (WHERE state = 'MERGED') AS merged,
|
|
55
|
+
count(*) FILTER (WHERE state = 'CLOSED') AS closed,
|
|
56
|
+
count(*) FILTER (WHERE state = 'OPEN') AS open,
|
|
57
|
+
count(*) FILTER (WHERE state IN ('MERGED', 'CLOSED')
|
|
58
|
+
AND (f.first_merge IS NULL OR w.created_at <= f.first_merge)) AS newcomer_decided,
|
|
59
|
+
count(*) FILTER (WHERE w.merged_at IS NOT NULL AND w.merged_at = f.first_merge) AS newcomer_merged,
|
|
60
|
+
count(*) FILTER (WHERE first_review_at IS NOT NULL
|
|
61
|
+
AND first_review_by IS DISTINCT FROM w.author_login AND rv.kind = 'insider') AS reviewed,
|
|
62
|
+
median(epoch(first_review_at - created_at) / 3600) FILTER (
|
|
63
|
+
WHERE first_review_at IS NOT NULL AND first_review_by IS DISTINCT FROM w.author_login
|
|
64
|
+
AND rv.kind = 'insider')
|
|
65
|
+
AS median_review_hours
|
|
66
|
+
FROM w LEFT JOIN firsts f USING (repo_id, author_login)
|
|
67
|
+
-- Only a maintainer's review counts as a response; review bots answer within minutes.
|
|
68
|
+
LEFT JOIN people rv ON rv.repo_id = w.repo_id AND rv.login = w.first_review_by
|
|
69
|
+
WHERE w.author_class = 'outsider' GROUP BY w.repo_id),
|
|
70
|
+
mix AS (
|
|
71
|
+
SELECT repo_id, count(*) AS window_prs,
|
|
72
|
+
count(*) FILTER (WHERE author_class = 'ai') AS ai_prs,
|
|
73
|
+
count(*) FILTER (WHERE author_class = 'bot') AS bot_prs,
|
|
74
|
+
count(*) FILTER (WHERE author_class = 'insider') AS insider_prs
|
|
75
|
+
FROM w GROUP BY repo_id)
|
|
76
|
+
SELECT span.repo_id, rm.name, rm.stars,
|
|
77
|
+
-- When the PR cap cut history short, say how many days we really cover.
|
|
78
|
+
CASE WHEN span.truncated THEN date_diff('day', span.window_start, $now) ELSE $window END AS window_days,
|
|
79
|
+
NOT span.truncated AS window_complete,
|
|
80
|
+
coalesce(o.outsider_prs, 0) AS outsider_prs, coalesce(o.merged, 0) AS merged,
|
|
81
|
+
coalesce(o.closed, 0) AS closed, coalesce(o.open, 0) AS open,
|
|
82
|
+
o.merged::DOUBLE / nullif(o.merged + o.closed, 0) AS merge_rate,
|
|
83
|
+
wilson_lo(o.merged, o.merged + o.closed) AS merge_low, wilson_hi(o.merged, o.merged + o.closed) AS merge_high,
|
|
84
|
+
coalesce(o.merged + o.closed, 0) >= $min_decided AS merge_enough,
|
|
85
|
+
coalesce(o.newcomer_decided, 0) AS newcomer_decided, coalesce(o.newcomer_merged, 0) AS newcomer_merged,
|
|
86
|
+
o.newcomer_merged::DOUBLE / nullif(o.newcomer_decided, 0) AS newcomer_rate,
|
|
87
|
+
wilson_lo(o.newcomer_merged, o.newcomer_decided) AS newcomer_low,
|
|
88
|
+
wilson_hi(o.newcomer_merged, o.newcomer_decided) AS newcomer_high,
|
|
89
|
+
coalesce(o.newcomer_decided, 0) >= $min_decided AS newcomer_enough,
|
|
90
|
+
coalesce(o.reviewed, 0) AS reviewed, o.median_review_hours,
|
|
91
|
+
coalesce(o.reviewed, 0) >= $min_reviewed AS review_enough,
|
|
92
|
+
coalesce(mix.window_prs, 0) AS window_prs, coalesce(mix.ai_prs, 0) AS ai_prs,
|
|
93
|
+
coalesce(mix.bot_prs, 0) AS bot_prs, coalesce(mix.insider_prs, 0) AS insider_prs
|
|
94
|
+
FROM span LEFT JOIN o USING (repo_id) LEFT JOIN mix USING (repo_id) LEFT JOIN repo_meta rm USING (repo_id)""",
|
|
95
|
+
{"since": since, "now": now, "cap": rules.pr_cap, "window": rules.window_days,
|
|
96
|
+
"min_decided": rules.min_decided, "min_reviewed": rules.min_reviewed})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class Rules:
|
|
6
|
+
window_days: int = 365
|
|
7
|
+
min_decided: int = 20
|
|
8
|
+
min_closed: int = 10
|
|
9
|
+
min_reviewed: int = 5
|
|
10
|
+
claim_days: int = 14
|
|
11
|
+
interest_days: int = 30
|
|
12
|
+
stale_days: int = 180
|
|
13
|
+
pr_cap: int = 300
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
RULES = Rules()
|
|
17
|
+
|
|
18
|
+
# Accounts that act like bots but are typed as users; extend when the hand-check finds more.
|
|
19
|
+
KNOWN_BOTS = frozenset({
|
|
20
|
+
"miss-islington", "bors", "homu", "k8s-ci-robot", "k8s-triage-robot", "k8s-merge-robot", "pytorchbot",
|
|
21
|
+
"facebook-github-bot", "googlebot", "google-cla", "copybara-service", "tensorflow-jenkins", "rustbot",
|
|
22
|
+
"rust-highfive", "rust-timer", "nodejs-github-bot", "chromium-wpt-export-bot", "mergify", "kodiakhq",
|
|
23
|
+
"allcontributors", "stale", "netlify", "vercel", "coveralls", "codecov-commenter", "sonarcloud",
|
|
24
|
+
"openshift-ci-robot", "openshift-merge-robot", "ti-chi-bot", "ti-srebot", "elasticmachine", "apache-bot",
|
|
25
|
+
"copilot-pull-request-reviewer", "coderabbitai", "gemini-code-assist", "sourcery-ai", "greptile-apps",
|
|
26
|
+
"home-assistant", "welcome", "linear", "similar-issues-ai", "oss-sentinel-ai", "hfla-graphql-app",
|
|
27
|
+
# CI accounts that open failing-test issues (seen 2026-10-16).
|
|
28
|
+
"kibanamachine", "elasticsearchmachine", "brave-builds", "gordontheturtle",
|
|
29
|
+
})
|
|
30
|
+
# Comments carry no account type, so bots that only comment are known by their names (seen on 2,545 repos).
|
|
31
|
+
BOT_NAME_PATTERN = r"(bot|-ci|github-actions|-automation|-integration)$"
|
|
32
|
+
UNKNOWN_LOGINS = frozenset({"ghost"})
|
|
33
|
+
# Accounts that are AI coding agents, not ordinary automation; their PRs count as AI-written.
|
|
34
|
+
AI_AGENT_LOGINS = frozenset({"copilot-swe-agent", "devin-ai-integration", "chatgpt-codex-connector", "cursor-agent",
|
|
35
|
+
"sweep-ai", "openhands-agent", "openhands-ai"})
|
|
36
|
+
# Plain words a person can also use as a login: AI only when the account is a bot.
|
|
37
|
+
AI_AGENT_BOT_NAMES = frozenset({"copilot", "claude", "codex", "cursor", "devin"})
|
|
38
|
+
|
|
39
|
+
# Real comments also say "...speaker and would like to work on this" and "I'll open a PR" (symfony#66111).
|
|
40
|
+
CLAIM_PATTERN = (r"(i('d| would) (like|love) to (work on|take|tackle|pick)"
|
|
41
|
+
r"|\band would (like|love) to (work on|take|tackle|pick up) (this|it)\b"
|
|
42
|
+
r"|(can|could|may) i (work on|take|pick|tackle|try)"
|
|
43
|
+
r"|assign (this|it) to me|please assign|^/assign|i('m| am) (working|taking) (on )?(this|it)"
|
|
44
|
+
r"|let me (take|work on|try|pick)|i('ll| will) (take|work on|pick up|try) (this|it)"
|
|
45
|
+
r"|i('ll| will) (open|submit|send|raise|create) (a )?(pr|pull request)\b)")
|
|
46
|
+
WAIT_PATTERN = (r"(please (wait|hold)|on hold|blocked (by|on)|needs? (design|discussion|decision|triage)"
|
|
47
|
+
r"|not (ready|accepting)|we('re| are) (still )?(discussing|deciding)|do not (open|send) (a )?(pr|pull))")
|
|
48
|
+
# Surnames that end in "bot" (seen: adamrtalbot); RE2 has no lookbehind, so they are excluded separately.
|
|
49
|
+
HUMAN_BOT_NAMES = r"(talbot|abbot|cabot|sabot)$"
|
|
50
|
+
# Labels (after norm_label) that say the issue is not ready for a PR, e.g. "status: blocked", "needs info".
|
|
51
|
+
# Anchored: real labels "triage accepted", "never stale", "question bank" and "triage needs pr" mean ready or a topic.
|
|
52
|
+
PREFIX = r"^((status|state|type|kind|resolution|issue|s|t) )?"
|
|
53
|
+
WAITING = r"(design|discussion|decision|info|information|feedback|repro|reproduction|investigation|confirmation)"
|
|
54
|
+
# Also labels saying someone is on it ("in progress", "claimed", "gssoc assigned") or it is done ("fixed").
|
|
55
|
+
WAIT_LABEL_PATTERN = (rf"{PREFIX}(blocked|on hold|wontfix|won t fix|invalid|duplicate|not planned|"
|
|
56
|
+
rf"maybe later|(\d+ )?in progress|work in progress|wip|claimed( \w+)?|"
|
|
57
|
+
rf"taken|has pr|pr open|pr submitted|fixed|resolved( pending close)?|(wait|waiting) for review)$"
|
|
58
|
+
rf"|\bneeds? (more )?{WAITING}\b|\b(awaiting|waiting for|waiting on|pending|requires) "
|
|
59
|
+
rf"({WAITING}|response|reply)\b|\btriage needs {WAITING}\b"
|
|
60
|
+
r"|^(gssoc|gsoc|ssoc|hacktoberfest|kwoc|swoc|jwoc|iwoc|dwoc|osci)\w* assigned$")
|
|
61
|
+
# "Not settled yet" labels (triage, question, proposal). Repos add them to every new issue or keep them after a
|
|
62
|
+
# decision (golang: "Proposal" + "Proposal-Accepted"), so a beginner or help label added later outweighs them.
|
|
63
|
+
TRIAGE_LABEL_PATTERN = (rf"{PREFIX}(triage|untriaged|unconfirmed|question|discussion|in discussion|rfc|proposal)$"
|
|
64
|
+
r"|\bneeds? triage\b|\b(awaiting|waiting for|pending|requires) triage\b"
|
|
65
|
+
r"|\btriage (needed|pending|required)\b")
|
|
66
|
+
# Titles like "[RFC] ..." or "Epic: ..." and labels like "epic" mark trackers and debates, not tasks.
|
|
67
|
+
KINDS = r"(epic|rfc|discussion|tracking|tracker|meta|roadmap|proposal|umbrella|poll|survey)"
|
|
68
|
+
DISCUSSION_TITLE = rf"^\s*[\[(]\s*{KINDS}\s*[\])]|^\s*{KINDS}\s*:|\btracking issue\b|^\s*dependency dashboard\s*$"
|
|
69
|
+
LABEL_KINDS = r"(epic|tracking|tracker|roadmap|umbrella|poll|survey)"
|
|
70
|
+
DISCUSSION_LABEL = rf"^((type|kind) )?{LABEL_KINDS}$"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import duckdb
|
|
4
|
+
import pyarrow as pa
|
|
5
|
+
|
|
6
|
+
from mergescout.crawl.writer import SCHEMAS
|
|
7
|
+
|
|
8
|
+
# One row per key survives: the most recently fetched copy.
|
|
9
|
+
KEYS = {"repo_meta": ["repo_id"], "prs": ["repo_id", "number"], "issues": ["repo_id", "number"],
|
|
10
|
+
"comments": ["repo_id", "number", "comment_id", "created_at"], "closed_issues": ["repo_id", "number"],
|
|
11
|
+
"issue_events": ["repo_id", "number", "event", "observed_at"]}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_silver(con: duckdb.DuckDBPyConnection, roots: list[Path]) -> dict[str, int]:
|
|
15
|
+
con.execute("SET TimeZone = 'UTC'") # day counts must not depend on the machine's clock zone
|
|
16
|
+
counts = {}
|
|
17
|
+
for table, key in KEYS.items():
|
|
18
|
+
files = sorted(str(p) for root in roots for p in (root / "crawl" / table).glob("*.parquet"))
|
|
19
|
+
con.register(f"{table}_empty", pa.Table.from_pylist([], schema=SCHEMAS[table]))
|
|
20
|
+
source = f"SELECT * FROM {table}_empty"
|
|
21
|
+
if files:
|
|
22
|
+
con.read_parquet(files, union_by_name=True).create_view(f"{table}_raw", replace=True)
|
|
23
|
+
# BY NAME fills columns that older batches do not have with nulls.
|
|
24
|
+
source += f" UNION ALL BY NAME SELECT * FROM {table}_raw"
|
|
25
|
+
con.execute(f"""
|
|
26
|
+
CREATE OR REPLACE TABLE {table} AS
|
|
27
|
+
SELECT * EXCLUDE (rn) FROM (
|
|
28
|
+
SELECT *, row_number() OVER (PARTITION BY {", ".join(key)}
|
|
29
|
+
ORDER BY fetched_at DESC NULLS LAST, run_id DESC) AS rn
|
|
30
|
+
FROM ({source})) WHERE rn = 1""")
|
|
31
|
+
# fetchall, not fetchone: DuckDB 2 refuses any other work while a result is half read.
|
|
32
|
+
counts[table] = con.execute(f"SELECT count(*) FROM {table}").fetchall()[0][0]
|
|
33
|
+
return counts
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The most-used npm and PyPI packages (by repos depending on them, from ecosyste.ms), so the evidence covers what people
|
|
2
|
+
add, not only what crawled repos happen to upgrade."""
|
|
3
|
+
from mergescout.signals.bump_ledger import valid_name
|
|
4
|
+
from mergescout.signals.bumps import _norm
|
|
5
|
+
from mergescout.signals.deps_map import http_get_json
|
|
6
|
+
|
|
7
|
+
REGISTRIES = {"npm": "npmjs.org", "pypi": "pypi.org"}
|
|
8
|
+
URL = ("https://packages.ecosyste.ms/api/v1/registries/{registry}/packages"
|
|
9
|
+
"?sort=dependent_repos_count&order=desc&per_page=100&page={page}")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fetch_top(n: int = 1000, get_json=None) -> list[tuple[str, str]]:
|
|
13
|
+
"""The top n of each registry, most-used first; a page that fails ends that registry's list for today."""
|
|
14
|
+
get_json = get_json or http_get_json
|
|
15
|
+
out = []
|
|
16
|
+
for eco, registry in REGISTRIES.items():
|
|
17
|
+
for page in range(1, -(-n // 100) + 1):
|
|
18
|
+
try:
|
|
19
|
+
rows = get_json(URL.format(registry=registry, page=page))
|
|
20
|
+
except Exception: # noqa: BLE001
|
|
21
|
+
break
|
|
22
|
+
if not isinstance(rows, list) or not rows:
|
|
23
|
+
break
|
|
24
|
+
out += [(eco, _norm(eco, r["name"])) for r in rows
|
|
25
|
+
if isinstance(r, dict) and isinstance(r.get("name"), str) and valid_name(eco, r["name"])]
|
|
26
|
+
return list(dict.fromkeys(out))
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
|
|
4
|
+
import duckdb
|
|
5
|
+
|
|
6
|
+
from mergescout.signals.triage_math import buckets, calibration, fit
|
|
7
|
+
|
|
8
|
+
DEFINITION = ("outside contributors' PRs opened 30 to 395 days ago; outcome = merged within 30 days of opening; "
|
|
9
|
+
"every feature is as of the PR's opening: the author's earlier PRs and merges here, and the repo's rate over "
|
|
10
|
+
"outside PRs opened 30 to 395 days before it. "
|
|
11
|
+
"Checked on one repo in five, never seen in training.")
|
|
12
|
+
MIN_REPO_PRS = 10
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def training_rows(con: duckdb.DuckDBPyConnection, now: datetime) -> list[dict]:
|
|
16
|
+
"""One row per outside PR whose 30-day outcome is known, with only what was known when it was opened."""
|
|
17
|
+
rows = con.execute("""
|
|
18
|
+
WITH outs AS (
|
|
19
|
+
SELECT repo_id, number, created_at, author_login, additions, deletions, closing_issue_numbers,
|
|
20
|
+
(merged_at IS NOT NULL AND merged_at <= created_at + INTERVAL 30 DAY) AS merged30
|
|
21
|
+
FROM pr_class WHERE author_class = 'outsider' AND author_login IS NOT NULL),
|
|
22
|
+
-- The repo's rate as of each PR: outside PRs opened 30-395 days earlier, whose 30-day outcome was known by then.
|
|
23
|
+
rated AS (
|
|
24
|
+
SELECT o.*, count(*) OVER w AS past_n, sum(merged30::INT) OVER w AS past_k FROM outs o
|
|
25
|
+
WINDOW w AS (PARTITION BY repo_id ORDER BY created_at
|
|
26
|
+
RANGE BETWEEN INTERVAL 395 DAY PRECEDING AND INTERVAL 30 DAY PRECEDING)),
|
|
27
|
+
base AS (SELECT * FROM rated WHERE created_at >= $lo AND created_at <= $hi),
|
|
28
|
+
merges AS (SELECT repo_id, author_login, merged_at FROM pr_class WHERE merged_at IS NOT NULL AND author_login IS NOT NULL),
|
|
29
|
+
-- Running totals read "as of" each PR's opening: cheap even for prolific authors.
|
|
30
|
+
here AS (SELECT repo_id, author_login, merged_at,
|
|
31
|
+
row_number() OVER (PARTITION BY repo_id, author_login ORDER BY merged_at) AS done FROM merges),
|
|
32
|
+
opened AS (SELECT repo_id, author_login, created_at,
|
|
33
|
+
row_number() OVER (PARTITION BY repo_id, author_login ORDER BY created_at) AS done
|
|
34
|
+
FROM pr_class WHERE author_login IS NOT NULL),
|
|
35
|
+
counted AS (
|
|
36
|
+
SELECT b.*, coalesce(h.done, 0) AS here_merged, coalesce(o.done, 0) AS here_opened
|
|
37
|
+
FROM base b
|
|
38
|
+
ASOF LEFT JOIN here h ON h.repo_id = b.repo_id AND h.author_login = b.author_login AND b.created_at > h.merged_at
|
|
39
|
+
ASOF LEFT JOIN opened o ON o.repo_id = b.repo_id AND o.author_login = b.author_login AND b.created_at > o.created_at)
|
|
40
|
+
SELECT repo_id, number, here_merged,
|
|
41
|
+
coalesce(additions, 0) + coalesce(deletions, 0) AS lines,
|
|
42
|
+
coalesce(len(closing_issue_numbers), 0) > 0 AS linked_issue,
|
|
43
|
+
-- GitHub reports the author's association as of the query, which leaks the outcome; history does not.
|
|
44
|
+
here_opened = 0 AS first_timer,
|
|
45
|
+
CASE WHEN past_n >= $min_repo THEN past_k::DOUBLE / past_n END AS repo_rate,
|
|
46
|
+
merged30 AS merged
|
|
47
|
+
FROM counted""",
|
|
48
|
+
{"lo": now - timedelta(days=395), "hi": now - timedelta(days=30), "min_repo": MIN_REPO_PRS}).fetchall()
|
|
49
|
+
cols = ("repo", "number", "here_merged", "lines", "linked_issue", "first_timer", "repo_rate", "merged")
|
|
50
|
+
return [dict(zip(cols, r)) for r in rows]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _held_out(repo_id: int) -> bool:
|
|
54
|
+
# One repo in five, picked by a real hash so neighbouring ids don't land together.
|
|
55
|
+
return int(hashlib.sha1(str(repo_id).encode()).hexdigest(), 16) % 5 == 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_triage_model(con: duckdb.DuckDBPyConnection, now: datetime) -> dict:
|
|
59
|
+
"""Fit on four repos in five, check calibration on the fifth, then publish a model fitted on all of them."""
|
|
60
|
+
rows = []
|
|
61
|
+
for r in training_rows(con, now):
|
|
62
|
+
feats = buckets(r["here_merged"], r["lines"], r["linked_issue"], r["first_timer"], r["repo_rate"])
|
|
63
|
+
rows.append({**feats, "merged": bool(r["merged"]), "repo": r["repo"]})
|
|
64
|
+
train = [r for r in rows if not _held_out(r["repo"])]
|
|
65
|
+
test = [r for r in rows if _held_out(r["repo"])]
|
|
66
|
+
evaluation = {"train_prs": len(train), "test_prs": len(test)}
|
|
67
|
+
if train and test:
|
|
68
|
+
evaluation |= calibration(fit(train), test)
|
|
69
|
+
model = fit(rows) if rows else fit([])
|
|
70
|
+
return {**model, "built_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "definition": DEFINITION, "prs": len(rows),
|
|
71
|
+
"evaluation": evaluation}
|