stackvitals 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mergescout/__init__.py +1 -0
- mergescout/archive/__init__.py +0 -0
- mergescout/archive/__main__.py +30 -0
- mergescout/archive/convert.py +83 -0
- mergescout/archive/events.py +55 -0
- mergescout/archive/manifest.py +45 -0
- mergescout/archive/stream.py +83 -0
- mergescout/bench/__init__.py +1 -0
- mergescout/bench/__main__.py +135 -0
- mergescout/bench/ci.py +257 -0
- mergescout/bench/deploys.py +115 -0
- mergescout/bench/matched.py +22 -0
- mergescout/bench/prior.py +42 -0
- mergescout/bench/report.py +230 -0
- mergescout/bench/shared.py +112 -0
- mergescout/bench/stars.py +57 -0
- mergescout/cli.py +89 -0
- mergescout/config.py +35 -0
- mergescout/crawl/__init__.py +0 -0
- mergescout/crawl/__main__.py +97 -0
- mergescout/crawl/chain.py +34 -0
- mergescout/crawl/fetch.py +146 -0
- mergescout/crawl/outcomes.py +69 -0
- mergescout/crawl/queries.py +250 -0
- mergescout/crawl/runner.py +176 -0
- mergescout/crawl/seed.py +77 -0
- mergescout/crawl/state.py +169 -0
- mergescout/crawl/writer.py +87 -0
- mergescout/discover/__init__.py +0 -0
- mergescout/discover/__main__.py +58 -0
- mergescout/discover/sweep.py +286 -0
- mergescout/github/__init__.py +0 -0
- mergescout/github/graphql.py +202 -0
- mergescout/github/identities.py +35 -0
- mergescout/github/releases.py +137 -0
- mergescout/labels.py +20 -0
- mergescout/mcp_server.py +228 -0
- mergescout/measure/__init__.py +0 -0
- mergescout/measure/__main__.py +75 -0
- mergescout/measure/api_cost.py +91 -0
- mergescout/measure/count_repos.py +30 -0
- mergescout/measure/jsonl.py +14 -0
- mergescout/measure/report.py +56 -0
- mergescout/measure/sample.py +37 -0
- mergescout/owners.py +22 -0
- mergescout/setup_check.py +64 -0
- mergescout/signals/__init__.py +0 -0
- mergescout/signals/__main__.py +96 -0
- mergescout/signals/bump_ledger.py +258 -0
- mergescout/signals/bumps.py +104 -0
- mergescout/signals/check.py +141 -0
- mergescout/signals/community_bench.py +149 -0
- mergescout/signals/delivery_bench.py +103 -0
- mergescout/signals/deps_map.py +261 -0
- mergescout/signals/deps_trends.py +117 -0
- mergescout/signals/evidence.py +94 -0
- mergescout/signals/exploits.py +43 -0
- mergescout/signals/flow_bench.py +258 -0
- mergescout/signals/identity.py +73 -0
- mergescout/signals/issues.py +76 -0
- mergescout/signals/labels.py +79 -0
- mergescout/signals/osv.py +232 -0
- mergescout/signals/owners_watch.py +80 -0
- mergescout/signals/repo_stats.py +96 -0
- mergescout/signals/rules.py +70 -0
- mergescout/signals/silver.py +33 -0
- mergescout/signals/top_packages.py +26 -0
- mergescout/signals/triage.py +71 -0
- mergescout/signals/triage_math.py +120 -0
- mergescout/signals/walls.py +91 -0
- mergescout/snapshot/__init__.py +0 -0
- mergescout/snapshot/__main__.py +215 -0
- mergescout/snapshot/build.py +147 -0
- mergescout/snapshot/publish.py +70 -0
- mergescout/snapshot/schema.py +34 -0
- mergescout/snapshot/score.py +30 -0
- mergescout/store/__init__.py +0 -0
- mergescout/store/__main__.py +46 -0
- mergescout/store/sync.py +148 -0
- mergescout/timeutil.py +11 -0
- mergescout/tool/__init__.py +0 -0
- mergescout/tool/action_pack.py +62 -0
- mergescout/tool/live.py +201 -0
- mergescout/tool/queries.py +293 -0
- mergescout/tool/refs.py +31 -0
- mergescout/tool/store.py +129 -0
- mergescout/tool/tracking.py +83 -0
- stackvitals/__init__.py +2 -0
- stackvitals/__main__.py +4 -0
- stackvitals/ci/__init__.py +1 -0
- stackvitals/ci/action.py +574 -0
- stackvitals/ci/analyze.py +577 -0
- stackvitals/ci/api.py +218 -0
- stackvitals/ci/automation.py +212 -0
- stackvitals/ci/bench.py +195 -0
- stackvitals/ci/burnin.py +102 -0
- stackvitals/ci/cli.py +570 -0
- stackvitals/ci/collect.py +239 -0
- stackvitals/ci/cost.py +107 -0
- stackvitals/ci/coverage.py +155 -0
- stackvitals/ci/feedback.py +699 -0
- stackvitals/ci/fingerprint.py +220 -0
- stackvitals/ci/gitlab.py +121 -0
- stackvitals/ci/gitlab_action.py +125 -0
- stackvitals/ci/impact.py +125 -0
- stackvitals/ci/investigate.py +59 -0
- stackvitals/ci/kinds.py +52 -0
- stackvitals/ci/monitors.py +105 -0
- stackvitals/ci/owners.py +116 -0
- stackvitals/ci/quarantine_runtime.py +90 -0
- stackvitals/ci/report.py +435 -0
- stackvitals/ci/retry.py +67 -0
- stackvitals/ci/selfheal.py +102 -0
- stackvitals/ci/shards.py +120 -0
- stackvitals/ci/share.py +59 -0
- stackvitals/ci/store.py +93 -0
- stackvitals/ci/testreports.py +351 -0
- stackvitals/ci/tickets.py +106 -0
- stackvitals/ci/track_record.json +11 -0
- stackvitals/ci/triage.py +130 -0
- stackvitals/ci/triggers.py +147 -0
- stackvitals/ci/upload.py +85 -0
- stackvitals/ci/validate.py +91 -0
- stackvitals/cli.py +111 -0
- stackvitals/codeowners.py +69 -0
- stackvitals/community/__init__.py +0 -0
- stackvitals/community/action.py +140 -0
- stackvitals/community/affiliation.py +51 -0
- stackvitals/community/analyze.py +507 -0
- stackvitals/community/build.py +56 -0
- stackvitals/community/chaoss.py +70 -0
- stackvitals/community/cli.py +79 -0
- stackvitals/community/collect.py +240 -0
- stackvitals/community/context.py +54 -0
- stackvitals/community/report.py +250 -0
- stackvitals/connectors/__init__.py +244 -0
- stackvitals/connectors/ai_spend.py +190 -0
- stackvitals/connectors/base.py +82 -0
- stackvitals/connectors/check.py +686 -0
- stackvitals/connectors/copilot.py +104 -0
- stackvitals/connectors/discord.py +103 -0
- stackvitals/connectors/discourse.py +117 -0
- stackvitals/connectors/incident_io.py +106 -0
- stackvitals/connectors/jira.py +210 -0
- stackvitals/connectors/linear.py +140 -0
- stackvitals/connectors/pagerduty.py +96 -0
- stackvitals/connectors/slack.py +110 -0
- stackvitals/connectors/sonar.py +77 -0
- stackvitals/connectors/store.py +120 -0
- stackvitals/connectors/tracker.py +108 -0
- stackvitals/connectors/writeback.py +105 -0
- stackvitals/contribute/__init__.py +1 -0
- stackvitals/contribute/brief.py +297 -0
- stackvitals/contribute/cli.py +64 -0
- stackvitals/core/__init__.py +1 -0
- stackvitals/core/actors.py +103 -0
- stackvitals/core/agent_ledger.py +120 -0
- stackvitals/core/agreements.py +95 -0
- stackvitals/core/ai_outcomes.py +276 -0
- stackvitals/core/alerts.py +224 -0
- stackvitals/core/appdata.py +112 -0
- stackvitals/core/bucket.py +190 -0
- stackvitals/core/bugs.py +43 -0
- stackvitals/core/calendar.py +34 -0
- stackvitals/core/capex.py +57 -0
- stackvitals/core/change_risk.py +181 -0
- stackvitals/core/changes.py +133 -0
- stackvitals/core/chat.py +143 -0
- stackvitals/core/config.py +373 -0
- stackvitals/core/dataflow.py +168 -0
- stackvitals/core/decisions.py +275 -0
- stackvitals/core/delivery.py +954 -0
- stackvitals/core/delivery_peers.py +98 -0
- stackvitals/core/delivery_report.py +429 -0
- stackvitals/core/deliverycli.py +147 -0
- stackvitals/core/devlake_dora.sql +485 -0
- stackvitals/core/digest.py +333 -0
- stackvitals/core/explaincli.py +107 -0
- stackvitals/core/explore.py +296 -0
- stackvitals/core/export.py +342 -0
- stackvitals/core/exportcli.py +117 -0
- stackvitals/core/findings.py +350 -0
- stackvitals/core/health.py +94 -0
- stackvitals/core/identity.py +82 -0
- stackvitals/core/insights.py +365 -0
- stackvitals/core/insightscli.py +43 -0
- stackvitals/core/installdoc.py +69 -0
- stackvitals/core/investment.py +187 -0
- stackvitals/core/joins.py +43 -0
- stackvitals/core/llmsdoc.py +40 -0
- stackvitals/core/merge_queue.py +70 -0
- stackvitals/core/metrics.py +109 -0
- stackvitals/core/model.py +285 -0
- stackvitals/core/projectcli.py +66 -0
- stackvitals/core/propose.py +86 -0
- stackvitals/core/pulse.py +85 -0
- stackvitals/core/recorded.py +145 -0
- stackvitals/core/releases.py +39 -0
- stackvitals/core/requests.py +528 -0
- stackvitals/core/resync.py +35 -0
- stackvitals/core/schema.py +205 -0
- stackvitals/core/services.py +203 -0
- stackvitals/core/settings.py +96 -0
- stackvitals/core/sqlcli.py +111 -0
- stackvitals/core/stats.py +222 -0
- stackvitals/core/store.py +125 -0
- stackvitals/core/summary.py +104 -0
- stackvitals/core/time.py +29 -0
- stackvitals/core/tomledit.py +149 -0
- stackvitals/core/uicli.py +138 -0
- stackvitals/core/webapp.py +79 -0
- stackvitals/core/what_changed.py +76 -0
- stackvitals/core/workspace.py +194 -0
- stackvitals/deps/__init__.py +1 -0
- stackvitals/deps/action.py +299 -0
- stackvitals/deps/actions_vulns.py +89 -0
- stackvitals/deps/cli.py +77 -0
- stackvitals/deps/dossier.py +341 -0
- stackvitals/deps/evidence.py +44 -0
- stackvitals/deps/fixplan.py +40 -0
- stackvitals/deps/guarddog.py +32 -0
- stackvitals/deps/hygiene.py +47 -0
- stackvitals/deps/imports.py +113 -0
- stackvitals/deps/lockfiles.py +198 -0
- stackvitals/deps/lookalike.py +80 -0
- stackvitals/deps/reach.py +59 -0
- stackvitals/deps/release.py +110 -0
- stackvitals/deps/runtimes.py +161 -0
- stackvitals/deps/supply.py +454 -0
- stackvitals/deps/vulns.py +588 -0
- stackvitals/editors/__init__.py +40 -0
- stackvitals/editors/vscode/extension.js +101 -0
- stackvitals/editors/vscode/marks.js +92 -0
- stackvitals/editors/vscode/package.json +26 -0
- stackvitals/flow/__init__.py +1 -0
- stackvitals/flow/action.py +178 -0
- stackvitals/flow/analyze.py +418 -0
- stackvitals/flow/automate.py +72 -0
- stackvitals/flow/build.py +46 -0
- stackvitals/flow/cli.py +93 -0
- stackvitals/flow/code_hosts.py +146 -0
- stackvitals/flow/collect.py +451 -0
- stackvitals/flow/gitlab_mr.py +75 -0
- stackvitals/flow/knowledge.py +202 -0
- stackvitals/flow/peers.py +90 -0
- stackvitals/flow/preflight.py +210 -0
- stackvitals/flow/report.py +253 -0
- stackvitals/flow/rules.py +135 -0
- stackvitals/flow/store.py +108 -0
- stackvitals/flow/survival.py +84 -0
- stackvitals/flow/triage.py +62 -0
- stackvitals/mcp_server.py +670 -0
- stackvitals/portal/__init__.py +0 -0
- stackvitals/portal/action.py +205 -0
- stackvitals/portal/batch.py +80 -0
- stackvitals/portal/campaigns.py +104 -0
- stackvitals/portal/catalog.py +104 -0
- stackvitals/portal/checks.py +63 -0
- stackvitals/portal/cli.py +172 -0
- stackvitals/portal/descriptor.py +105 -0
- stackvitals/portal/export.py +43 -0
- stackvitals/portal/github.py +48 -0
- stackvitals/portal/golden.py +95 -0
- stackvitals/portal/history.py +30 -0
- stackvitals/portal/inventory.py +264 -0
- stackvitals/portal/owners.py +78 -0
- stackvitals/portal/pages.py +153 -0
- stackvitals/portal/rollup.py +32 -0
- stackvitals/portal/scorecard.py +366 -0
- stackvitals/security/__init__.py +2 -0
- stackvitals/security/probes.py +154 -0
- stackvitals/security/repo_checks.py +132 -0
- stackvitals/security/workflows.py +111 -0
- stackvitals/shell.py +107 -0
- stackvitals/site.py +51 -0
- stackvitals/web/app/LICENSES.md +428 -0
- stackvitals/web/app/boot.js +1 -0
- stackvitals/web/app/chunks/AddRepo-KRPZXWCY.js +1 -0
- stackvitals/web/app/chunks/SetupRunning-XWFQAKRZ.js +1 -0
- stackvitals/web/app/chunks/accuracy-data-OQTDXI7Y.js +1 -0
- stackvitals/web/app/chunks/agents-data-BAL6QAOF.js +11 -0
- stackvitals/web/app/chunks/chunk-24LFKFZ6.js +1 -0
- stackvitals/web/app/chunks/chunk-3G4SQNUC.js +1 -0
- stackvitals/web/app/chunks/chunk-3IXWEOIJ.js +154 -0
- stackvitals/web/app/chunks/chunk-3ROQFHPV.js +1 -0
- stackvitals/web/app/chunks/chunk-45PGAQRO.js +8 -0
- stackvitals/web/app/chunks/chunk-4H53OYLG.js +1 -0
- stackvitals/web/app/chunks/chunk-4LUI7O5P.js +1 -0
- stackvitals/web/app/chunks/chunk-4SQPQ5TB.js +1 -0
- stackvitals/web/app/chunks/chunk-4WKQTWUC.js +2 -0
- stackvitals/web/app/chunks/chunk-576YHDEK.js +8 -0
- stackvitals/web/app/chunks/chunk-6BDBXA6V.js +1 -0
- stackvitals/web/app/chunks/chunk-6RLVD2DT.js +1 -0
- stackvitals/web/app/chunks/chunk-763NH7PP.js +1 -0
- stackvitals/web/app/chunks/chunk-76WAVKUG.js +1 -0
- stackvitals/web/app/chunks/chunk-7II2BD5F.js +1 -0
- stackvitals/web/app/chunks/chunk-7TJCBOAY.js +1 -0
- stackvitals/web/app/chunks/chunk-ASYNLVOP.js +1 -0
- stackvitals/web/app/chunks/chunk-CTKDYZMG.js +1 -0
- stackvitals/web/app/chunks/chunk-CVB26NMC.js +1 -0
- stackvitals/web/app/chunks/chunk-G7NGB5UX.js +1 -0
- stackvitals/web/app/chunks/chunk-GPVLJRZD.js +1 -0
- stackvitals/web/app/chunks/chunk-GQYFN4PE.js +75 -0
- stackvitals/web/app/chunks/chunk-GT4EAKAO.js +2 -0
- stackvitals/web/app/chunks/chunk-IYMBFPCV.js +1 -0
- stackvitals/web/app/chunks/chunk-LAAGCPU3.js +1 -0
- stackvitals/web/app/chunks/chunk-MAPYGK3P.js +1 -0
- stackvitals/web/app/chunks/chunk-P5TXDD45.js +1 -0
- stackvitals/web/app/chunks/chunk-PABICGBL.js +1 -0
- stackvitals/web/app/chunks/chunk-RPXHAG6F.js +1 -0
- stackvitals/web/app/chunks/chunk-RQZPSJ2Y.js +1 -0
- stackvitals/web/app/chunks/chunk-S2ZBMEVA.js +1 -0
- stackvitals/web/app/chunks/chunk-S654BBEP.js +6 -0
- stackvitals/web/app/chunks/chunk-T7QVTNXF.js +1 -0
- stackvitals/web/app/chunks/chunk-THFUOCPC.js +273 -0
- stackvitals/web/app/chunks/chunk-TO4VVRZ7.js +1 -0
- stackvitals/web/app/chunks/chunk-VQ5YXO54.js +1 -0
- stackvitals/web/app/chunks/chunk-VWKSMFWJ.js +1 -0
- stackvitals/web/app/chunks/chunk-Y6LUXDM6.js +1 -0
- stackvitals/web/app/chunks/chunk-YTANGYYH.js +1 -0
- stackvitals/web/app/chunks/connect-KONYWYTT.js +1 -0
- stackvitals/web/app/chunks/connections-data-2DYS4UVW.js +32 -0
- stackvitals/web/app/chunks/logos-N5IINUAM.js +1 -0
- stackvitals/web/app/chunks/more-KMJMJQD3.js +10 -0
- stackvitals/web/app/chunks/more-PC6FVS3P.js +1 -0
- stackvitals/web/app/chunks/more-RCIVCUDE.js +2 -0
- stackvitals/web/app/chunks/more-ZKKRY6NO.css +1 -0
- stackvitals/web/app/chunks/sealbox-35YL4EUH.js +3 -0
- stackvitals/web/app/chunks/session-DYYTOE2K.js +1 -0
- stackvitals/web/app/chunks/settingsApply-2HRJEZYM.js +1 -0
- stackvitals/web/app/chunks/settingsReview-GX2FZQ4M.js +3 -0
- stackvitals/web/app/chunks/uPlot.esm-OIWT3AHJ.js +3 -0
- stackvitals/web/app/demo/ci/impact.json +1 -0
- stackvitals/web/app/demo/ci/quarantine.json +1 -0
- stackvitals/web/app/demo/ci/tickets.json +1 -0
- stackvitals/web/app/demo/mobile/alerts.json +1 -0
- stackvitals/web/app/demo/mobile/app.json +1 -0
- stackvitals/web/app/demo/mobile/community.json +1 -0
- stackvitals/web/app/demo/mobile/connectors.json +1 -0
- stackvitals/web/app/demo/mobile/data.json +1 -0
- stackvitals/web/app/demo/mobile/decisions.json +1 -0
- stackvitals/web/app/demo/mobile/delivery.json +1 -0
- stackvitals/web/app/demo/mobile/deps.json +1 -0
- stackvitals/web/app/demo/mobile/flow.json +1 -0
- stackvitals/web/app/demo/mobile/health.json +1 -0
- stackvitals/web/app/demo/mobile/insights.json +1 -0
- stackvitals/web/app/demo/mobile/pulse.json +1 -0
- stackvitals/web/app/demo/mobile/security.json +1 -0
- stackvitals/web/app/demo/mobile/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/mobile/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/mobile/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/mobile/summary/manifest.json +1 -0
- stackvitals/web/app/demo/mobile/summary/prs.jsonl +19 -0
- stackvitals/web/app/demo/mobile/summary/tests.jsonl +1 -0
- stackvitals/web/app/demo/mobile/supply.json +1 -0
- stackvitals/web/app/demo/mobile/vulns.json +1 -0
- stackvitals/web/app/demo/payments-api/alerts.json +1 -0
- stackvitals/web/app/demo/payments-api/app.json +1 -0
- stackvitals/web/app/demo/payments-api/community.json +1 -0
- stackvitals/web/app/demo/payments-api/connectors.json +1 -0
- stackvitals/web/app/demo/payments-api/data.json +1 -0
- stackvitals/web/app/demo/payments-api/decisions.json +1 -0
- stackvitals/web/app/demo/payments-api/delivery.json +1 -0
- stackvitals/web/app/demo/payments-api/deps.json +1 -0
- stackvitals/web/app/demo/payments-api/flow.json +1 -0
- stackvitals/web/app/demo/payments-api/health.json +1 -0
- stackvitals/web/app/demo/payments-api/insights.json +1 -0
- stackvitals/web/app/demo/payments-api/pulse.json +1 -0
- stackvitals/web/app/demo/payments-api/security.json +1 -0
- stackvitals/web/app/demo/payments-api/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/payments-api/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/payments-api/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/payments-api/summary/manifest.json +1 -0
- stackvitals/web/app/demo/payments-api/summary/prs.jsonl +22 -0
- stackvitals/web/app/demo/payments-api/summary/tests.jsonl +3 -0
- stackvitals/web/app/demo/payments-api/supply.json +1 -0
- stackvitals/web/app/demo/payments-api/vulns.json +1 -0
- stackvitals/web/app/demo/portal.json +1 -0
- stackvitals/web/app/demo/settings.json +1 -0
- stackvitals/web/app/demo/web-app/alerts.json +1 -0
- stackvitals/web/app/demo/web-app/app.json +1 -0
- stackvitals/web/app/demo/web-app/community.json +1 -0
- stackvitals/web/app/demo/web-app/connectors.json +1 -0
- stackvitals/web/app/demo/web-app/data.json +1 -0
- stackvitals/web/app/demo/web-app/decisions.json +1 -0
- stackvitals/web/app/demo/web-app/delivery.json +1 -0
- stackvitals/web/app/demo/web-app/deps.json +1 -0
- stackvitals/web/app/demo/web-app/flow.json +1 -0
- stackvitals/web/app/demo/web-app/health.json +1 -0
- stackvitals/web/app/demo/web-app/insights.json +1 -0
- stackvitals/web/app/demo/web-app/pulse.json +1 -0
- stackvitals/web/app/demo/web-app/security.json +1 -0
- stackvitals/web/app/demo/web-app/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/web-app/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/web-app/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/web-app/summary/manifest.json +1 -0
- stackvitals/web/app/demo/web-app/summary/prs.jsonl +18 -0
- stackvitals/web/app/demo/web-app/summary/tests.jsonl +2 -0
- stackvitals/web/app/demo/web-app/supply.json +1 -0
- stackvitals/web/app/demo/web-app/vulns.json +1 -0
- stackvitals/web/app/fonts/plex-mono-400.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-400.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-600.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-condensed-700.woff2 +0 -0
- stackvitals/web/app/help/accuracy.json +1 -0
- stackvitals/web/app/help/connectors.json +1 -0
- stackvitals/web/app/help/getting-started.json +1 -0
- stackvitals/web/app/help/index.json +1 -0
- stackvitals/web/app/help/modules.json +1 -0
- stackvitals/web/app/help/privacy-and-security.json +1 -0
- stackvitals/web/app/help/reference.json +1 -0
- stackvitals/web/app/main.css +1 -0
- stackvitals/web/app/main.js +1 -0
- stackvitals/web/app/pages/about.css +1 -0
- stackvitals/web/app/pages/about.js +1 -0
- stackvitals/web/app/pages/activity.css +1 -0
- stackvitals/web/app/pages/activity.js +1 -0
- stackvitals/web/app/pages/agents.css +1 -0
- stackvitals/web/app/pages/agents.js +2 -0
- stackvitals/web/app/pages/ci.css +1 -0
- stackvitals/web/app/pages/ci.js +1 -0
- stackvitals/web/app/pages/community.css +1 -0
- stackvitals/web/app/pages/community.js +1 -0
- stackvitals/web/app/pages/connections.css +1 -0
- stackvitals/web/app/pages/connections.js +6 -0
- stackvitals/web/app/pages/delivery.css +1 -0
- stackvitals/web/app/pages/delivery.js +45 -0
- stackvitals/web/app/pages/deps.css +1 -0
- stackvitals/web/app/pages/deps.js +7 -0
- stackvitals/web/app/pages/explore.css +1 -0
- stackvitals/web/app/pages/explore.js +7 -0
- stackvitals/web/app/pages/health.css +1 -0
- stackvitals/web/app/pages/health.js +1 -0
- stackvitals/web/app/pages/help.css +1 -0
- stackvitals/web/app/pages/help.js +6 -0
- stackvitals/web/app/pages/insights.css +1 -0
- stackvitals/web/app/pages/insights.js +1 -0
- stackvitals/web/app/pages/investment.css +1 -0
- stackvitals/web/app/pages/investment.js +3 -0
- stackvitals/web/app/pages/overview.css +1 -0
- stackvitals/web/app/pages/overview.js +1 -0
- stackvitals/web/app/pages/portal.css +1 -0
- stackvitals/web/app/pages/portal.js +51 -0
- stackvitals/web/app/pages/prs.css +1 -0
- stackvitals/web/app/pages/prs.js +4 -0
- stackvitals/web/app/pages/settings.css +1 -0
- stackvitals/web/app/pages/settings.js +1 -0
- stackvitals/web/app/pages/setup.css +1 -0
- stackvitals/web/app/pages/setup.js +2 -0
- stackvitals/web/app/pages/standing.css +1 -0
- stackvitals/web/app/pages/standing.js +1 -0
- stackvitals/web/app/pages/welcome-sheet.js +1 -0
- stackvitals/web/app/pages/welcome.js +1 -0
- stackvitals/web/app/settings-workflow.json +1 -0
- stackvitals/web/index.html +34 -0
- stackvitals/web/site/index.html +108 -0
- stackvitals/web/site/integrations.html +15 -0
- stackvitals/web/site/setup.html +15 -0
- stackvitals/web/site/site.css +68 -0
- stackvitals-0.1.0.dist-info/METADATA +134 -0
- stackvitals-0.1.0.dist-info/RECORD +465 -0
- stackvitals-0.1.0.dist-info/WHEEL +4 -0
- stackvitals-0.1.0.dist-info/entry_points.txt +3 -0
- stackvitals-0.1.0.dist-info/licenses/LICENSE +202 -0
- stackvitals-0.1.0.dist-info/licenses/NOTICE +16 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from mergescout.crawl.state import CrawlState
|
|
6
|
+
|
|
7
|
+
LAST_RUN = "last-run.json"
|
|
8
|
+
# Runs that used their whole window had points to spare; others stopped on GitHub's limits and should pause first.
|
|
9
|
+
FULL_RUN = {"time", "limit"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_last_run(data_dir: Path, out: dict) -> None:
|
|
13
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
keep = {k: out.get(k) for k in ("repos", "points", "failed", "reason")}
|
|
15
|
+
(data_dir / LAST_RUN).write_text(json.dumps(keep), encoding="utf-8")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def next_step(state: CrawlState, data_dir: Path, now: datetime, after_discover: bool = False) -> str:
|
|
19
|
+
# The relay: GitHub's hourly schedule is best effort, so each finished run decides what starts next.
|
|
20
|
+
marker = data_dir / "last-discover"
|
|
21
|
+
discovered = marker.read_text(encoding="utf-8").strip() if marker.exists() else ""
|
|
22
|
+
if discovered != now.strftime("%Y-%m-%d"):
|
|
23
|
+
return "discover"
|
|
24
|
+
if state.due_count() == 0:
|
|
25
|
+
return "none"
|
|
26
|
+
if after_discover:
|
|
27
|
+
return "crawl"
|
|
28
|
+
try:
|
|
29
|
+
last = json.loads((data_dir / LAST_RUN).read_text(encoding="utf-8"))
|
|
30
|
+
except (OSError, ValueError):
|
|
31
|
+
return "none"
|
|
32
|
+
if not last.get("repos"):
|
|
33
|
+
return "none"
|
|
34
|
+
return "crawl" if last.get("reason") in FULL_RUN else "crawl-later"
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
|
|
4
|
+
from mergescout.crawl.queries import (CLOSED_ISSUES_QUERY, ISSUE_PAGE_QUERY, PR_PAGE_QUERY, REPO_META_QUERY,
|
|
5
|
+
is_dep_bot, issues_by_number_query, parse_closed_issues, parse_issues, parse_prs,
|
|
6
|
+
parse_repo_meta, prs_by_number_query)
|
|
7
|
+
from mergescout.github.graphql import GraphQLError, NotFound, RateLimited, too_heavy
|
|
8
|
+
from mergescout.labels import HOT, OPPORTUNITY_LABELS, has_opportunity, norm_label
|
|
9
|
+
|
|
10
|
+
# Pages of 100 take 5-9 s on big repos and hit GitHub's 10 s limit under load; 50 costs the same points.
|
|
11
|
+
MIN_PAGE = 10
|
|
12
|
+
# GitHub charges by the page size asked for, so a refresh starts small: most repos change little between crawls.
|
|
13
|
+
REFRESH_FIRST_PAGE = 20
|
|
14
|
+
BY_NUMBER = 50
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def cutoff(now: datetime, days: int) -> str:
|
|
18
|
+
return (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class RepoBundle:
|
|
23
|
+
repo_id: int
|
|
24
|
+
name: str
|
|
25
|
+
status: str = "ok"
|
|
26
|
+
meta: dict | None = None
|
|
27
|
+
prs: list[dict] = field(default_factory=list)
|
|
28
|
+
issues: list[dict] = field(default_factory=list)
|
|
29
|
+
comments: list[dict] = field(default_factory=list)
|
|
30
|
+
points: int = 0
|
|
31
|
+
error: str | None = None
|
|
32
|
+
full: bool = True
|
|
33
|
+
deleted_issues: list[int] = field(default_factory=list)
|
|
34
|
+
closed_issues: list[dict] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _walk(gql, query: str, variables: dict, connection: str, cap: int, page: int, bundle: RepoBundle,
|
|
38
|
+
stop=None, first: int | None = None) -> list[dict]:
|
|
39
|
+
# Pages through one connection, shrinking the page when GitHub says it is too heavy.
|
|
40
|
+
cursor, nodes = None, []
|
|
41
|
+
while len(nodes) < cap:
|
|
42
|
+
size = min(first, page) if first and cursor is None else page
|
|
43
|
+
try:
|
|
44
|
+
result = gql.query(query, {**variables, "cursor": cursor, "n": min(size, cap - len(nodes))})
|
|
45
|
+
except NotFound:
|
|
46
|
+
raise
|
|
47
|
+
except GraphQLError as exc:
|
|
48
|
+
if too_heavy(exc) and page > MIN_PAGE:
|
|
49
|
+
page = max(MIN_PAGE, page // 2)
|
|
50
|
+
continue
|
|
51
|
+
raise
|
|
52
|
+
bundle.points += result.cost
|
|
53
|
+
conn = (result.data.get("repository") or {}).get(connection) or {}
|
|
54
|
+
batch = [n for n in conn.get("nodes") or [] if n]
|
|
55
|
+
nodes.extend(batch)
|
|
56
|
+
info = conn.get("pageInfo") or {}
|
|
57
|
+
if not batch or (stop and stop(batch[-1])) or not info.get("hasNextPage") or not info.get("endCursor"):
|
|
58
|
+
break
|
|
59
|
+
cursor = info["endCursor"]
|
|
60
|
+
return nodes[:cap]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fetch_repo(gql, repo_id: int, name: str, now: datetime, pr_cap: int = 300, issue_cap: int = 200,
|
|
64
|
+
issue_days: int = 90, page: int = 50, track: list[int] = (), track_cap: int = 200,
|
|
65
|
+
pr_days: int = 365, changed_since: datetime | None = None, closed_cap: int = 100,
|
|
66
|
+
closed_days: int = 365) -> RepoBundle:
|
|
67
|
+
owner, repo = name.split("/", 1)
|
|
68
|
+
bundle = RepoBundle(repo_id, name, full=changed_since is None)
|
|
69
|
+
base = {"owner": owner, "name": repo}
|
|
70
|
+
if changed_since is None:
|
|
71
|
+
# First or periodic full crawl: PRs from the last year, open issues active in the last 90 days.
|
|
72
|
+
pr_vars, pr_key, pr_since = {**base, "order": "CREATED_AT"}, "createdAt", cutoff(now, pr_days)
|
|
73
|
+
since, states = cutoff(now, issue_days), ["OPEN"]
|
|
74
|
+
else:
|
|
75
|
+
# Refresh: only PRs and issues (open or closed) that changed since the last crawl; settings wait for the
|
|
76
|
+
# next full crawl.
|
|
77
|
+
pr_since = changed_since.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
78
|
+
pr_vars, pr_key, since, states = {**base, "order": "UPDATED_AT"}, "updatedAt", pr_since, ["OPEN", "CLOSED"]
|
|
79
|
+
track = ()
|
|
80
|
+
first = REFRESH_FIRST_PAGE if changed_since else None
|
|
81
|
+
try:
|
|
82
|
+
if changed_since is None:
|
|
83
|
+
meta = gql.query(REPO_META_QUERY, base)
|
|
84
|
+
bundle.points += meta.cost
|
|
85
|
+
bundle.meta = parse_repo_meta(repo_id, meta.data)
|
|
86
|
+
pr_nodes = _walk(gql, PR_PAGE_QUERY, pr_vars, "pullRequests", pr_cap, page, bundle,
|
|
87
|
+
stop=lambda n: (n.get(pr_key) or "") < pr_since, first=first)
|
|
88
|
+
pr_nodes = [n for n in pr_nodes if (n.get(pr_key) or "") >= pr_since]
|
|
89
|
+
# Issues come newest-updated first; stop once a page reaches the cutoff.
|
|
90
|
+
issue_nodes = _walk(gql, ISSUE_PAGE_QUERY, {**base, "since": since, "states": states}, "issues", issue_cap,
|
|
91
|
+
page, bundle, stop=lambda n: (n.get("updatedAt") or "") < since, first=first)
|
|
92
|
+
fresh = [n for n in issue_nodes if (n.get("updatedAt") or "") >= since]
|
|
93
|
+
# Tracked issues missing from the open list were closed, deleted or went quiet: look them up by number.
|
|
94
|
+
seen = {n.get("number") for n in fresh}
|
|
95
|
+
missing = [n for n in track if n not in seen][:track_cap]
|
|
96
|
+
for i in range(0, len(missing), BY_NUMBER):
|
|
97
|
+
chunk = missing[i:i + BY_NUMBER]
|
|
98
|
+
result = gql.query(issues_by_number_query(chunk), base, partial=True)
|
|
99
|
+
bundle.points += result.cost
|
|
100
|
+
found = result.data.get("repository") or {}
|
|
101
|
+
for number in chunk:
|
|
102
|
+
node = found.get(f"i{number}")
|
|
103
|
+
if node:
|
|
104
|
+
fresh.append(node)
|
|
105
|
+
else:
|
|
106
|
+
bundle.deleted_issues.append(number)
|
|
107
|
+
open_labels = [lb.get("name") for n in fresh if n.get("state", "OPEN") == "OPEN"
|
|
108
|
+
for lb in (n.get("labels") or {}).get("nodes") or [] if lb]
|
|
109
|
+
if changed_since is None and closed_cap and has_opportunity(open_labels):
|
|
110
|
+
# Label trust needs past beginner issues and who fixed them; only full crawls of hot repos pay for it.
|
|
111
|
+
closed_since = cutoff(now, closed_days)
|
|
112
|
+
# GitHub matches label names exactly, so ask with this repo's own spellings ("good first issue :wave:").
|
|
113
|
+
hot_names = [n for n in (bundle.meta or {}).get("label_names") or [] if norm_label(n) in HOT]
|
|
114
|
+
closed = _walk(gql, CLOSED_ISSUES_QUERY, {**base, "labels": hot_names or list(OPPORTUNITY_LABELS)},
|
|
115
|
+
"issues", closed_cap, page, bundle,
|
|
116
|
+
stop=lambda n: (n.get("updatedAt") or "") < closed_since)
|
|
117
|
+
bundle.closed_issues = parse_closed_issues(
|
|
118
|
+
repo_id, [n for n in closed if (n.get("updatedAt") or "") >= closed_since])
|
|
119
|
+
except NotFound as exc:
|
|
120
|
+
bundle.status, bundle.meta, bundle.error = "gone", None, str(exc)
|
|
121
|
+
return bundle
|
|
122
|
+
bundle.prs = parse_prs(repo_id, pr_nodes)
|
|
123
|
+
bundle.issues, bundle.comments = parse_issues(repo_id, fresh)
|
|
124
|
+
add_bot_outcomes(gql, base, bundle)
|
|
125
|
+
return bundle
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def add_bot_outcomes(gql, base: dict, bundle: RepoBundle) -> None:
|
|
129
|
+
"""Check result and commit count of finished dependency-bot PRs, 50 per query; only these need them (upgrade outcomes)."""
|
|
130
|
+
rows = {r["number"]: r for r in bundle.prs if r["state"] in ("MERGED", "CLOSED")
|
|
131
|
+
and is_dep_bot(r["author_login"], r["head_ref"])}
|
|
132
|
+
numbers = sorted(rows)
|
|
133
|
+
for i in range(0, len(numbers), BY_NUMBER):
|
|
134
|
+
chunk = numbers[i:i + BY_NUMBER]
|
|
135
|
+
try:
|
|
136
|
+
result = gql.query(prs_by_number_query(chunk), base, partial=True)
|
|
137
|
+
except (GraphQLError, RateLimited):
|
|
138
|
+
# Outcomes are an extra: the PRs are kept without them and the next full crawl tries again.
|
|
139
|
+
return
|
|
140
|
+
bundle.points += result.cost
|
|
141
|
+
found = result.data.get("repository") or {}
|
|
142
|
+
for n in chunk:
|
|
143
|
+
commits = (found.get(f"pr{n}") or {}).get("commits") or {}
|
|
144
|
+
last = ((commits.get("nodes") or [{}])[-1] or {}).get("commit") or {}
|
|
145
|
+
rows[n]["ci_state"] = (last.get("statusCheckRollup") or {}).get("state")
|
|
146
|
+
rows[n]["commit_count"] = commits.get("totalCount")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from mergescout.crawl.state import CrawlState
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _iso(value) -> str | None:
|
|
5
|
+
if value is None or isinstance(value, str):
|
|
6
|
+
return value
|
|
7
|
+
return value.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def issue_state_rows(issues: list[dict]) -> list[dict]:
|
|
11
|
+
return [{"repo_id": i["repo_id"], "number": i["number"], "state": i.get("state"),
|
|
12
|
+
"state_reason": i.get("state_reason"), "assignees": i.get("assignees") or 0,
|
|
13
|
+
"linked_open_prs": i.get("linked_open_prs") or 0, "linked_merged_prs": i.get("linked_merged_prs") or 0,
|
|
14
|
+
"comments": i.get("comments_total") or 0, "labels": "|".join(i.get("labels") or []),
|
|
15
|
+
"updated_at": _iso(i.get("updated_at"))} for i in issues]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def diff_events(before: list[dict], after: list[dict]) -> list[dict]:
|
|
19
|
+
prev = {(r["repo_id"], r["number"]): r for r in before}
|
|
20
|
+
events = []
|
|
21
|
+
for r in after:
|
|
22
|
+
p = prev.get((r["repo_id"], r["number"]))
|
|
23
|
+
if p is None:
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
def add(kind, detail=None):
|
|
27
|
+
events.append({"repo_id": r["repo_id"], "number": r["number"], "event": kind, "detail": detail,
|
|
28
|
+
"observed_at": r.get("observed_at")})
|
|
29
|
+
if r["state"] == "DELETED":
|
|
30
|
+
add("gone", "deleted or transferred")
|
|
31
|
+
continue
|
|
32
|
+
if p["assignees"] == 0 and r["assignees"] > 0:
|
|
33
|
+
add("claimed")
|
|
34
|
+
if p["assignees"] > 0 and r["assignees"] == 0:
|
|
35
|
+
add("unclaimed")
|
|
36
|
+
if r["linked_open_prs"] > p["linked_open_prs"]:
|
|
37
|
+
add("pr_opened")
|
|
38
|
+
if r["linked_merged_prs"] > p["linked_merged_prs"]:
|
|
39
|
+
add("pr_merged")
|
|
40
|
+
if p["state"] == "OPEN" and r["state"] == "CLOSED":
|
|
41
|
+
add("closed", r.get("state_reason"))
|
|
42
|
+
return events
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def plan_outcomes(state: CrawlState, repo_id: int, issues: list[dict], observed_at: str, since: str | None = None,
|
|
46
|
+
deleted: list[int] = ()) -> tuple[list[dict], list[dict], list[int]]:
|
|
47
|
+
# Read-only: the runner applies the new state only after the repo's rows are on disk.
|
|
48
|
+
before = state.latest_issue_state(repo_id)
|
|
49
|
+
after = [{**r, "observed_at": observed_at} for r in issue_state_rows(issues)]
|
|
50
|
+
gone = [{"repo_id": repo_id, "number": n, "state": "DELETED", "observed_at": observed_at} for n in deleted]
|
|
51
|
+
events = diff_events(before, after + gone)
|
|
52
|
+
# Closed, deleted and quiet-for-90-days issues stop being tracked.
|
|
53
|
+
forget = [r["number"] for r in after if r["state"] != "OPEN"
|
|
54
|
+
or (since and r["updated_at"] and r["updated_at"] < since)] + list(deleted)
|
|
55
|
+
keep = [r for r in after if r["number"] not in set(forget)]
|
|
56
|
+
return events, keep, forget
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def apply_outcomes(state: CrawlState, repo_id: int, after: list[dict], forget: list[int]) -> None:
|
|
60
|
+
if after:
|
|
61
|
+
state.log_issue_state(after, after[0]["observed_at"])
|
|
62
|
+
state.forget_issues(repo_id, forget)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def events_for_repo(state: CrawlState, repo_id: int, issues: list[dict], observed_at: str, since: str | None = None,
|
|
66
|
+
deleted: list[int] = ()) -> list[dict]:
|
|
67
|
+
events, after, forget = plan_outcomes(state, repo_id, issues, observed_at, since, deleted)
|
|
68
|
+
apply_outcomes(state, repo_id, after, forget)
|
|
69
|
+
return events
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from mergescout.owners import owner_logins
|
|
4
|
+
from mergescout.timeutil import parse_time
|
|
5
|
+
|
|
6
|
+
RL = "rateLimit { cost remaining resetAt }"
|
|
7
|
+
OWNER_KEYS = ("codeowners", "codeowners2", "codeowners3", "maintainers", "maintainers2", "owners")
|
|
8
|
+
|
|
9
|
+
PR_PAGE_QUERY = f"""
|
|
10
|
+
query($owner: String!, $name: String!, $cursor: String, $n: Int!, $order: IssueOrderField!) {{
|
|
11
|
+
{RL}
|
|
12
|
+
repository(owner: $owner, name: $name) {{
|
|
13
|
+
pullRequests(first: $n, after: $cursor, orderBy: {{field: $order, direction: DESC}}) {{
|
|
14
|
+
pageInfo {{ hasNextPage endCursor }}
|
|
15
|
+
nodes {{
|
|
16
|
+
number title createdAt updatedAt mergedAt closedAt state headRefName additions deletions changedFiles
|
|
17
|
+
author {{ __typename login }} authorAssociation mergedBy {{ login }} bodyText
|
|
18
|
+
reviews(first: 5) {{ nodes {{ submittedAt authorAssociation author {{ __typename login }} }} }}
|
|
19
|
+
closingIssuesReferences(first: 5) {{ nodes {{ number }} }}
|
|
20
|
+
}}
|
|
21
|
+
}}
|
|
22
|
+
}}
|
|
23
|
+
}}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
ISSUE_FIELDS = """
|
|
27
|
+
number title bodyText state stateReason createdAt updatedAt closedAt
|
|
28
|
+
author { login } authorAssociation
|
|
29
|
+
labels(first: 10) { nodes { name } }
|
|
30
|
+
assignees(first: 5) { totalCount }
|
|
31
|
+
closedByPullRequestsReferences(first: 5, includeClosedPrs: true) { nodes { number state author { login } authorAssociation } }
|
|
32
|
+
comments(last: 5) { totalCount nodes { databaseId createdAt author { __typename login } authorAssociation bodyText } }
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
ISSUE_PAGE_QUERY = f"""
|
|
36
|
+
query($owner: String!, $name: String!, $cursor: String, $n: Int!, $since: DateTime!, $states: [IssueState!]) {{
|
|
37
|
+
{RL}
|
|
38
|
+
repository(owner: $owner, name: $name) {{
|
|
39
|
+
issues(first: $n, after: $cursor, states: $states, filterBy: {{since: $since}},
|
|
40
|
+
orderBy: {{field: UPDATED_AT, direction: DESC}}) {{
|
|
41
|
+
pageInfo {{ hasNextPage endCursor }}
|
|
42
|
+
nodes {{ {ISSUE_FIELDS} }}
|
|
43
|
+
}}
|
|
44
|
+
}}
|
|
45
|
+
}}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Dependency-update bots, by login or branch (self-hosted Renovate runs under many names).
|
|
50
|
+
DEP_BOT = re.compile(r"^(dependabot|renovate)", re.I)
|
|
51
|
+
# Titles are kept only where they carry a signal: bot bumps (package and versions) and human reverts or downgrades.
|
|
52
|
+
REVERT_TITLE = re.compile(r"^\s*(revert|downgrade|roll ?back)\b", re.I)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_dep_bot(login: str | None, head: str | None) -> bool:
|
|
56
|
+
return bool(DEP_BOT.match(login or "") or DEP_BOT.match(head or ""))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def prs_by_number_query(numbers: list[int]) -> str:
|
|
60
|
+
# One query reads the last commit's check result and the commit count of up to 50 finished bot PRs (1 point).
|
|
61
|
+
parts = "\n".join(f" pr{int(n)}: pullRequest(number: {int(n)}) {{ commits(last: 1) {{ totalCount "
|
|
62
|
+
f"nodes {{ commit {{ statusCheckRollup {{ state }} }} }} }} }}" for n in numbers)
|
|
63
|
+
return f"query($owner: String!, $name: String!) {{\n {RL}\n repository(owner: $owner, name: $name) {{\n{parts}\n }}\n}}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def issues_by_number_query(numbers: list[int]) -> str:
|
|
67
|
+
# One query re-checks up to 50 tracked issues that dropped out of the open list.
|
|
68
|
+
parts = "\n".join(f" i{int(n)}: issue(number: {int(n)}) {{ {ISSUE_FIELDS} }}" for n in numbers)
|
|
69
|
+
return f"query($owner: String!, $name: String!) {{\n {RL}\n repository(owner: $owner, name: $name) {{\n{parts}\n }}\n}}"
|
|
70
|
+
|
|
71
|
+
REPO_META_QUERY = f"""
|
|
72
|
+
query($owner: String!, $name: String!) {{
|
|
73
|
+
{RL}
|
|
74
|
+
repository(owner: $owner, name: $name) {{
|
|
75
|
+
databaseId nameWithOwner stargazerCount forkCount pushedAt isArchived
|
|
76
|
+
hasIssuesEnabled hasDiscussionsEnabled hasProjectsEnabled
|
|
77
|
+
primaryLanguage {{ name }} licenseInfo {{ spdxId }} defaultBranchRef {{ name }}
|
|
78
|
+
openPrs: pullRequests(states: OPEN) {{ totalCount }} openIssues: issues(states: OPEN) {{ totalCount }}
|
|
79
|
+
contributing: object(expression: "HEAD:CONTRIBUTING.md") {{ oid ... on Blob {{ text }} }}
|
|
80
|
+
contributing2: object(expression: "HEAD:.github/CONTRIBUTING.md") {{ oid ... on Blob {{ text }} }}
|
|
81
|
+
contributing3: object(expression: "HEAD:docs/CONTRIBUTING.md") {{ oid ... on Blob {{ text }} }}
|
|
82
|
+
aipolicy: object(expression: "HEAD:AI_POLICY.md") {{ oid }}
|
|
83
|
+
prtemplate: object(expression: "HEAD:.github/PULL_REQUEST_TEMPLATE.md") {{ oid }}
|
|
84
|
+
readme: object(expression: "HEAD:README.md") {{ ... on Blob {{ text }} }}
|
|
85
|
+
labelNames: labels(first: 100) {{ nodes {{ name }} }}
|
|
86
|
+
codeowners: object(expression: "HEAD:.github/CODEOWNERS") {{ ... on Blob {{ text }} }}
|
|
87
|
+
codeowners2: object(expression: "HEAD:CODEOWNERS") {{ ... on Blob {{ text }} }}
|
|
88
|
+
codeowners3: object(expression: "HEAD:docs/CODEOWNERS") {{ ... on Blob {{ text }} }}
|
|
89
|
+
maintainers: object(expression: "HEAD:MAINTAINERS") {{ ... on Blob {{ text }} }}
|
|
90
|
+
maintainers2: object(expression: "HEAD:MAINTAINERS.md") {{ ... on Blob {{ text }} }}
|
|
91
|
+
owners: object(expression: "HEAD:OWNERS") {{ ... on Blob {{ text }} }}
|
|
92
|
+
releases(first: 100, orderBy: {{field: CREATED_AT, direction: DESC}}) {{ totalCount nodes {{ createdAt }} }}
|
|
93
|
+
}}
|
|
94
|
+
}}
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
AI_BRANCH = re.compile(r"^(copilot|codex|cursor|devin|claude)[/-]", re.I)
|
|
98
|
+
AI_TRAILER = re.compile(r"co-authored-by:\s*(claude|codex|copilot|cursor|devin)", re.I)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def ai_tag(pr: dict) -> str | None:
|
|
102
|
+
login = (pr.get("author_login") or "").lower()
|
|
103
|
+
if "devin" in login:
|
|
104
|
+
return "devin"
|
|
105
|
+
if pr.get("author_type") == "Bot" or login.endswith("[bot]"):
|
|
106
|
+
return "bot"
|
|
107
|
+
m = AI_BRANCH.match(pr.get("head_ref") or "")
|
|
108
|
+
if m:
|
|
109
|
+
return m.group(1).lower()
|
|
110
|
+
m = AI_TRAILER.search(pr.get("body") or "")
|
|
111
|
+
if m:
|
|
112
|
+
return m.group(1).lower()
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_prs(repo_id: int, nodes: list) -> list[dict]:
|
|
117
|
+
rows = []
|
|
118
|
+
for n in nodes:
|
|
119
|
+
if not n:
|
|
120
|
+
continue
|
|
121
|
+
author = n.get("author") or {}
|
|
122
|
+
reviews = [r for r in (n.get("reviews") or {}).get("nodes") or [] if r]
|
|
123
|
+
review = reviews[0] if reviews else {}
|
|
124
|
+
# Review bots often answer first; the first person's review is the real maintainer response.
|
|
125
|
+
human = next((r for r in reviews if (r.get("author") or {}).get("__typename") != "Bot"), {})
|
|
126
|
+
row = {
|
|
127
|
+
"repo_id": repo_id, "number": n.get("number"),
|
|
128
|
+
"created_at": parse_time(n.get("createdAt")), "merged_at": parse_time(n.get("mergedAt")),
|
|
129
|
+
"closed_at": parse_time(n.get("closedAt")), "state": n.get("state"),
|
|
130
|
+
"author_login": author.get("login"), "author_type": author.get("__typename"),
|
|
131
|
+
"author_association": n.get("authorAssociation"), "merged_by": (n.get("mergedBy") or {}).get("login"),
|
|
132
|
+
"head_ref": n.get("headRefName"), "additions": n.get("additions"), "deletions": n.get("deletions"),
|
|
133
|
+
"changed_files": n.get("changedFiles"),
|
|
134
|
+
"first_review_at": parse_time(review.get("submittedAt")),
|
|
135
|
+
"first_review_by": (review.get("author") or {}).get("login"),
|
|
136
|
+
"first_review_association": review.get("authorAssociation"),
|
|
137
|
+
"first_human_review_at": parse_time(human.get("submittedAt")),
|
|
138
|
+
"first_human_review_by": (human.get("author") or {}).get("login"),
|
|
139
|
+
"first_human_review_association": human.get("authorAssociation"),
|
|
140
|
+
"closing_issue_numbers": [c["number"] for c in (n.get("closingIssuesReferences") or {}).get("nodes") or []
|
|
141
|
+
if c],
|
|
142
|
+
"ci_state": None, "commit_count": None,
|
|
143
|
+
}
|
|
144
|
+
row["ai_tag"] = ai_tag({**row, "body": n.get("bodyText") or ""})
|
|
145
|
+
title = n.get("title") or ""
|
|
146
|
+
row["title"] = title[:300] if is_dep_bot(row["author_login"], row["head_ref"]) or REVERT_TITLE.match(title) else None
|
|
147
|
+
rows.append(row)
|
|
148
|
+
return rows
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _fix_fields(refs: list[dict]) -> dict:
|
|
152
|
+
# The merged PR is the fix; otherwise the first linked PR shows who tried.
|
|
153
|
+
fix = next((p for p in refs if p.get("state") == "MERGED"), None) or (refs[0] if refs else {})
|
|
154
|
+
return {"fix_pr": fix.get("number"), "fix_pr_state": fix.get("state"),
|
|
155
|
+
"fix_author": (fix.get("author") or {}).get("login"), "fix_association": fix.get("authorAssociation")}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_issues(repo_id: int, nodes: list) -> tuple[list[dict], list[dict]]:
|
|
159
|
+
issues, comments = [], []
|
|
160
|
+
for n in nodes:
|
|
161
|
+
if not n:
|
|
162
|
+
continue
|
|
163
|
+
linked = [p for p in (n.get("closedByPullRequestsReferences") or {}).get("nodes") or [] if p]
|
|
164
|
+
cm = n.get("comments") or {}
|
|
165
|
+
issues.append({
|
|
166
|
+
"repo_id": repo_id, "number": n.get("number"), "title": n.get("title"),
|
|
167
|
+
"body_excerpt": (n.get("bodyText") or "")[:1000], "state": n.get("state"),
|
|
168
|
+
"state_reason": n.get("stateReason"),
|
|
169
|
+
"created_at": parse_time(n.get("createdAt")), "updated_at": parse_time(n.get("updatedAt")),
|
|
170
|
+
"closed_at": parse_time(n.get("closedAt")), "author_login": (n.get("author") or {}).get("login"),
|
|
171
|
+
"author_association": n.get("authorAssociation"),
|
|
172
|
+
"labels": [lb["name"] for lb in (n.get("labels") or {}).get("nodes") or [] if lb and lb.get("name")],
|
|
173
|
+
"assignees": (n.get("assignees") or {}).get("totalCount"),
|
|
174
|
+
"linked_open_prs": sum(p.get("state") == "OPEN" for p in linked),
|
|
175
|
+
"linked_merged_prs": sum(p.get("state") == "MERGED" for p in linked),
|
|
176
|
+
"linked_closed_prs": sum(p.get("state") == "CLOSED" for p in linked),
|
|
177
|
+
**_fix_fields(linked),
|
|
178
|
+
"comments_total": cm.get("totalCount"),
|
|
179
|
+
})
|
|
180
|
+
for c in cm.get("nodes") or []:
|
|
181
|
+
if c:
|
|
182
|
+
comments.append({"repo_id": repo_id, "number": n.get("number"), "comment_id": c.get("databaseId"),
|
|
183
|
+
"created_at": parse_time(c.get("createdAt")),
|
|
184
|
+
"author_login": (c.get("author") or {}).get("login"),
|
|
185
|
+
"author_type": (c.get("author") or {}).get("__typename"),
|
|
186
|
+
"author_association": c.get("authorAssociation"),
|
|
187
|
+
"body_excerpt": (c.get("bodyText") or "")[:500]})
|
|
188
|
+
return issues, comments
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def parse_repo_meta(repo_id: int, data: dict) -> dict:
|
|
192
|
+
r = data.get("repository") or {}
|
|
193
|
+
rel = r.get("releases") or {}
|
|
194
|
+
dates = [d for d in (parse_time(x.get("createdAt")) for x in rel.get("nodes") or [] if x) if d]
|
|
195
|
+
newest = max(dates) if dates else None
|
|
196
|
+
contrib = r.get("contributing") or r.get("contributing2") or r.get("contributing3") or {}
|
|
197
|
+
return {
|
|
198
|
+
"repo_id": repo_id, "name": r.get("nameWithOwner"), "stars": r.get("stargazerCount"),
|
|
199
|
+
"forks": r.get("forkCount"), "pushed_at": parse_time(r.get("pushedAt")),
|
|
200
|
+
# Backlog sizes: cheap here (one repo per query), slow in the 50-repo sweep.
|
|
201
|
+
"open_prs": (r.get("openPrs") or {}).get("totalCount"),
|
|
202
|
+
"open_issues": (r.get("openIssues") or {}).get("totalCount"),
|
|
203
|
+
"language": (r.get("primaryLanguage") or {}).get("name"), "archived": r.get("isArchived"),
|
|
204
|
+
"has_issues": r.get("hasIssuesEnabled"), "has_discussions": r.get("hasDiscussionsEnabled"),
|
|
205
|
+
"has_projects": r.get("hasProjectsEnabled"), "license": (r.get("licenseInfo") or {}).get("spdxId"),
|
|
206
|
+
"default_branch": (r.get("defaultBranchRef") or {}).get("name"),
|
|
207
|
+
"contributing_sha": contrib.get("oid"), "contributing_excerpt": (contrib.get("text") or "")[:4000],
|
|
208
|
+
"ai_policy_sha": (r.get("aipolicy") or {}).get("oid"), "pr_template_sha": (r.get("prtemplate") or {}).get("oid"),
|
|
209
|
+
"readme_excerpt": ((r.get("readme") or {}).get("text") or "")[:2000],
|
|
210
|
+
"last_release_at": newest,
|
|
211
|
+
"releases_12m": sum(1 for d in dates if newest and (newest - d).days <= 365),
|
|
212
|
+
"label_names": [lb["name"] for lb in (r.get("labelNames") or {}).get("nodes") or [] if lb and lb.get("name")],
|
|
213
|
+
# Logins only, never the file text: owner files can be large.
|
|
214
|
+
"owner_logins": sorted(owner_logins("\n".join((r.get(k) or {}).get("text") or "" for k in OWNER_KEYS))),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
CLOSED_ISSUES_QUERY = f"""
|
|
219
|
+
query($owner: String!, $name: String!, $cursor: String, $n: Int!, $labels: [String!]) {{
|
|
220
|
+
{RL}
|
|
221
|
+
repository(owner: $owner, name: $name) {{
|
|
222
|
+
issues(first: $n, after: $cursor, states: CLOSED, labels: $labels, orderBy: {{field: UPDATED_AT, direction: DESC}}) {{
|
|
223
|
+
pageInfo {{ hasNextPage endCursor }}
|
|
224
|
+
nodes {{
|
|
225
|
+
number createdAt updatedAt closedAt stateReason author {{ login }} authorAssociation
|
|
226
|
+
labels(first: 10) {{ nodes {{ name }} }}
|
|
227
|
+
closedByPullRequestsReferences(first: 3, includeClosedPrs: true) {{
|
|
228
|
+
nodes {{ number state author {{ login }} authorAssociation }}
|
|
229
|
+
}}
|
|
230
|
+
}}
|
|
231
|
+
}}
|
|
232
|
+
}}
|
|
233
|
+
}}
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def parse_closed_issues(repo_id: int, nodes: list) -> list[dict]:
|
|
238
|
+
rows = []
|
|
239
|
+
for n in nodes:
|
|
240
|
+
if not n:
|
|
241
|
+
continue
|
|
242
|
+
refs = [p for p in (n.get("closedByPullRequestsReferences") or {}).get("nodes") or [] if p]
|
|
243
|
+
rows.append({
|
|
244
|
+
"repo_id": repo_id, "number": n.get("number"), "created_at": parse_time(n.get("createdAt")),
|
|
245
|
+
"closed_at": parse_time(n.get("closedAt")), "state_reason": n.get("stateReason"),
|
|
246
|
+
"author_login": (n.get("author") or {}).get("login"), "author_association": n.get("authorAssociation"),
|
|
247
|
+
"labels": [lb["name"] for lb in (n.get("labels") or {}).get("nodes") or [] if lb and lb.get("name")],
|
|
248
|
+
**_fix_fields(refs),
|
|
249
|
+
})
|
|
250
|
+
return rows
|