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,146 @@
|
|
|
1
|
+
"""Bitbucket Cloud and Azure DevOps pull requests (and Bitbucket Pipelines deployments) in the shapes Delivery reads for
|
|
2
|
+
GitHub, as DevLake's plugins bring them in, with no server: read-only REST, public projects need no token.
|
|
3
|
+
|
|
4
|
+
stackvitals delivery workspace/repo --bitbucket # BITBUCKET_TOKEN for private ones
|
|
5
|
+
stackvitals delivery org/project/repo --azure # AZURE_DEVOPS_TOKEN (a PAT) for private ones
|
|
6
|
+
|
|
7
|
+
Each merged pull request's activity tells when someone else first answered and when it was approved; its merge commit
|
|
8
|
+
links it to the deployment that shipped it. Azure DevOps keeps deployments in release pipelines, outside the Git API:
|
|
9
|
+
send them with `stackvitals record`, or count merges (source = "merges")."""
|
|
10
|
+
import base64
|
|
11
|
+
import os
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from stackvitals.ci.api import APIError
|
|
17
|
+
|
|
18
|
+
BITBUCKET = "https://api.bitbucket.org/2.0"
|
|
19
|
+
AZURE = "https://dev.azure.com"
|
|
20
|
+
MAX_PRS = 400
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _iso(s: str | None) -> str | None:
|
|
24
|
+
if not s:
|
|
25
|
+
return None
|
|
26
|
+
t = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
27
|
+
return t.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _record(n: int, title: str, created: str, merged: str, author: str, base: str, head: str, sha: str | None,
|
|
31
|
+
events: list, talk: list, draft: bool = False) -> dict:
|
|
32
|
+
"""One pull request as Team Flow's record (the shape GitHub's and GitLab's take)."""
|
|
33
|
+
return {"t": "pr", "n": n, "ti": (title or "")[:200], "draft": draft, "st": "MERGED", "cr": _iso(created), "up": _iso(merged),
|
|
34
|
+
"mg": _iso(merged), "cl": _iso(merged), "au": author, "at": "User", "as": "MEMBER", "base": base, "head": head,
|
|
35
|
+
"mc": sha, "lb": [], "paths": [], "rq": [], "ev": sorted(events, key=lambda e: e[1] or ""), "cm": talk,
|
|
36
|
+
"fa": None, "nc": None}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Client:
|
|
40
|
+
"""A read-only GET with an optional token, a request budget and plain errors."""
|
|
41
|
+
def __init__(self, base: str, headers: dict | None = None, client: httpx.Client | None = None, budget: int = 900):
|
|
42
|
+
self.base, self.headers, self.budget, self.requests = base.rstrip("/"), headers or {}, budget, 0
|
|
43
|
+
self.client = client or httpx.Client(timeout=60, follow_redirects=True)
|
|
44
|
+
|
|
45
|
+
def get(self, url: str, params: dict | None = None) -> dict:
|
|
46
|
+
if self.requests >= self.budget:
|
|
47
|
+
raise APIError(f"stopped after {self.requests} requests (budget)")
|
|
48
|
+
self.requests += 1
|
|
49
|
+
resp = self.client.get(url if url.startswith("http") else f"{self.base}{url}", params=params, headers=self.headers)
|
|
50
|
+
if resp.status_code >= 400:
|
|
51
|
+
raise APIError(f"GET {url.split('?')[0][-80:]}: HTTP {resp.status_code}")
|
|
52
|
+
return resp.json()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def bitbucket(repo: str, now: datetime, days: int = 90, client: Client | None = None) -> tuple[dict, dict]:
|
|
56
|
+
"""(flow, repo facts) for a Bitbucket Cloud repo: merged PRs of the window with their activity, and deployments."""
|
|
57
|
+
tok = os.environ.get("BITBUCKET_TOKEN")
|
|
58
|
+
c = client or Client(BITBUCKET, {"Authorization": f"Bearer {tok}"} if tok else {})
|
|
59
|
+
since = (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S")
|
|
60
|
+
info = c.get(f"/repositories/{repo}")
|
|
61
|
+
closed, url = [], f"/repositories/{repo}/pullrequests"
|
|
62
|
+
params = {"state": "MERGED", "pagelen": 50, "q": f'updated_on > {since}', "sort": "-updated_on"}
|
|
63
|
+
while url and len(closed) < MAX_PRS:
|
|
64
|
+
page = c.get(url, params)
|
|
65
|
+
for p in page.get("values") or []:
|
|
66
|
+
author = (p.get("author") or {}).get("nickname") or (p.get("author") or {}).get("display_name")
|
|
67
|
+
events, talk, merged = [], [], None
|
|
68
|
+
try:
|
|
69
|
+
for a in c.get(f"/repositories/{repo}/pullrequests/{p['id']}/activity", {"pagelen": 50}).get("values") or []:
|
|
70
|
+
if a.get("approval"):
|
|
71
|
+
who = (a["approval"].get("user") or {}).get("nickname")
|
|
72
|
+
events.append(["review", _iso(a["approval"].get("date")), who, "APPROVED", "MEMBER", "User", False])
|
|
73
|
+
elif a.get("comment") and ((a["comment"].get("user") or {}).get("nickname")) != author:
|
|
74
|
+
talk.append([_iso(a["comment"].get("created_on")), (a["comment"].get("user") or {}).get("nickname"), "MEMBER", "User", False])
|
|
75
|
+
elif (a.get("update") or {}).get("state") == "MERGED":
|
|
76
|
+
merged = a["update"].get("date")
|
|
77
|
+
except APIError:
|
|
78
|
+
pass # no activity: the phases end at the merge
|
|
79
|
+
merged = merged or p.get("updated_on")
|
|
80
|
+
events.append(["merged", _iso(merged), (p.get("closed_by") or {}).get("nickname")])
|
|
81
|
+
closed.append(_record(p["id"], p.get("title"), p.get("created_on"), merged, author,
|
|
82
|
+
((p.get("destination") or {}).get("branch") or {}).get("name"),
|
|
83
|
+
((p.get("source") or {}).get("branch") or {}).get("name"),
|
|
84
|
+
(p.get("merge_commit") or {}).get("hash"), events, talk, bool(p.get("draft"))))
|
|
85
|
+
url, params = page.get("next"), None
|
|
86
|
+
envs = {}
|
|
87
|
+
try:
|
|
88
|
+
envs = {e["uuid"]: e.get("name") for e in c.get(f"/repositories/{repo}/environments", {"pagelen": 100}).get("values") or []}
|
|
89
|
+
except APIError:
|
|
90
|
+
pass
|
|
91
|
+
deps = []
|
|
92
|
+
try:
|
|
93
|
+
for d in c.get(f"/repositories/{repo}/deployments", {"pagelen": 100, "sort": "-state.started_on"}).get("values") or []:
|
|
94
|
+
state = ((d.get("state") or {}).get("status") or {}).get("name")
|
|
95
|
+
deps.append({"env": envs.get((d.get("environment") or {}).get("uuid")) or "", "at": _iso((d.get("state") or {}).get("started_on")),
|
|
96
|
+
"sha": ((d.get("release") or {}).get("commit") or {}).get("hash"),
|
|
97
|
+
"state": {"SUCCESSFUL": "SUCCESS", "FAILED": "FAILURE"}.get(state),
|
|
98
|
+
"done": _iso((d.get("state") or {}).get("completed_on"))})
|
|
99
|
+
except APIError:
|
|
100
|
+
pass
|
|
101
|
+
facts = {"default_branch": (info.get("mainbranch") or {}).get("name"), "private": bool(info.get("is_private")),
|
|
102
|
+
"deployments": [d for d in deps if d["state"] and d["at"]], "releases": []}
|
|
103
|
+
return {"closed": closed, "open": []}, facts
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def azure(path: str, now: datetime, days: int = 90, client: Client | None = None) -> tuple[dict, dict]:
|
|
107
|
+
"""(flow, repo facts) for an Azure DevOps repo (org/project/repo): completed PRs of the window with their threads."""
|
|
108
|
+
org, project, repo = path.split("/", 2)
|
|
109
|
+
tok = os.environ.get("AZURE_DEVOPS_TOKEN")
|
|
110
|
+
auth = {"Authorization": "Basic " + base64.b64encode(f":{tok}".encode()).decode()} if tok else {}
|
|
111
|
+
c = client or Client(f"{AZURE}/{org}/{project}/_apis/git", auth)
|
|
112
|
+
since = (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
113
|
+
info = c.get(f"/repositories/{repo}", {"api-version": "7.1"})
|
|
114
|
+
closed, skip = [], 0
|
|
115
|
+
while len(closed) < MAX_PRS:
|
|
116
|
+
page = c.get(f"/repositories/{repo}/pullrequests", {"searchCriteria.status": "completed", "searchCriteria.minTime": since,
|
|
117
|
+
"searchCriteria.queryTimeRangeType": "closed", "$top": 100, "$skip": skip,
|
|
118
|
+
"api-version": "7.1"}).get("value") or []
|
|
119
|
+
for p in page:
|
|
120
|
+
# Anonymous reads of a public project leave out uniqueName; the display name still tells people apart.
|
|
121
|
+
who_of = lambda x: (x or {}).get("uniqueName") or (x or {}).get("displayName") # noqa: E731
|
|
122
|
+
author = who_of(p.get("createdBy"))
|
|
123
|
+
events, talk = [], []
|
|
124
|
+
try:
|
|
125
|
+
threads = c.get(f"/repositories/{repo}/pullRequests/{p['pullRequestId']}/threads", {"api-version": "7.1"}).get("value") or []
|
|
126
|
+
except APIError:
|
|
127
|
+
threads = []
|
|
128
|
+
for th in threads:
|
|
129
|
+
vote = ((th.get("properties") or {}).get("CodeReviewThreadType") or {}).get("$value") == "VoteUpdate"
|
|
130
|
+
for cm in th.get("comments") or []:
|
|
131
|
+
who, at = who_of(cm.get("author")), _iso(cm.get("publishedDate"))
|
|
132
|
+
if vote and "approved" in (cm.get("content") or "").lower():
|
|
133
|
+
events.append(["review", at, who, "APPROVED", "MEMBER", "User", False])
|
|
134
|
+
elif cm.get("commentType") == "text" and who and who != author:
|
|
135
|
+
talk.append([at, who, "MEMBER", "User", False])
|
|
136
|
+
events.append(["merged", _iso(p.get("closedDate")), who_of(p.get("closedBy"))])
|
|
137
|
+
closed.append(_record(p["pullRequestId"], p.get("title"), p.get("creationDate"), p.get("closedDate"), author,
|
|
138
|
+
(p.get("targetRefName") or "").removeprefix("refs/heads/"),
|
|
139
|
+
(p.get("sourceRefName") or "").removeprefix("refs/heads/"),
|
|
140
|
+
(p.get("lastMergeCommit") or {}).get("commitId"), events, talk, bool(p.get("isDraft"))))
|
|
141
|
+
if len(page) < 100:
|
|
142
|
+
break
|
|
143
|
+
skip += 100
|
|
144
|
+
facts = {"default_branch": (info.get("defaultBranch") or "").removeprefix("refs/heads/") or None,
|
|
145
|
+
"private": (info.get("project") or {}).get("visibility") != "public", "deployments": [], "releases": []}
|
|
146
|
+
return {"closed": closed, "open": []}, facts
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
from mergescout.github.graphql import GraphQLError, RateLimited, too_heavy
|
|
6
|
+
from stackvitals.flow.store import FlowStore
|
|
7
|
+
|
|
8
|
+
# One page of PRs with their review history costs about 1 point per 25 PRs (measured on poetry and vite).
|
|
9
|
+
QUERY = """
|
|
10
|
+
query($owner:String!,$name:String!,$cursor:String,$n:Int!) {
|
|
11
|
+
rateLimit { cost remaining resetAt }
|
|
12
|
+
repository(owner:$owner,name:$name) {
|
|
13
|
+
pullRequests(first:$n, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}) {
|
|
14
|
+
pageInfo { hasNextPage endCursor }
|
|
15
|
+
nodes {
|
|
16
|
+
number title body isDraft state mergeable createdAt updatedAt mergedAt closedAt additions deletions changedFiles
|
|
17
|
+
author { __typename login } authorAssociation baseRefName headRefName headRepositoryOwner { login }
|
|
18
|
+
files(first:50) { nodes { path } }
|
|
19
|
+
closingIssuesReferences(first:1) { totalCount }
|
|
20
|
+
commits(last:1) { nodes { commit { committedDate authoredDate statusCheckRollup { state } } } }
|
|
21
|
+
first: commits(first:1) { totalCount nodes { commit { authoredDate } } }
|
|
22
|
+
mergeCommit { oid }
|
|
23
|
+
early: timelineItems(first:5, itemTypes:[ISSUE_COMMENT]) {
|
|
24
|
+
nodes { ... on IssueComment { createdAt author { __typename login } authorAssociation } } }
|
|
25
|
+
reviewRequests(first:10) { nodes { requestedReviewer { __typename ... on User { login } ... on Team { slug } } } }
|
|
26
|
+
events: timelineItems(first:60, itemTypes:[READY_FOR_REVIEW_EVENT, CONVERT_TO_DRAFT_EVENT, REVIEW_REQUESTED_EVENT,
|
|
27
|
+
REVIEW_REQUEST_REMOVED_EVENT, PULL_REQUEST_REVIEW, MERGED_EVENT, CLOSED_EVENT, REOPENED_EVENT]) {
|
|
28
|
+
filteredCount
|
|
29
|
+
nodes { __typename
|
|
30
|
+
... on ReadyForReviewEvent { createdAt }
|
|
31
|
+
... on ConvertToDraftEvent { createdAt }
|
|
32
|
+
... on ReviewRequestedEvent { createdAt requestedReviewer { __typename ... on User { login } ... on Team { slug } } }
|
|
33
|
+
... on ReviewRequestRemovedEvent { createdAt requestedReviewer { __typename ... on User { login } ... on Team { slug } } }
|
|
34
|
+
... on PullRequestReview { submittedAt state author { __typename login } authorAssociation body comments { totalCount } }
|
|
35
|
+
... on MergedEvent { createdAt actor { login } }
|
|
36
|
+
... on ClosedEvent { createdAt actor { login } }
|
|
37
|
+
... on ReopenedEvent { createdAt }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
labels(first:20) { nodes { name } }
|
|
41
|
+
marks: timelineItems(last:20, itemTypes:[LABELED_EVENT, UNLABELED_EVENT, ASSIGNED_EVENT, UNASSIGNED_EVENT]) {
|
|
42
|
+
nodes { __typename ... on LabeledEvent { createdAt label { name } } ... on UnlabeledEvent { createdAt label { name } }
|
|
43
|
+
... on AssignedEvent { createdAt assignee { __typename ... on User { login } } }
|
|
44
|
+
... on UnassignedEvent { createdAt assignee { __typename ... on User { login } } } }
|
|
45
|
+
}
|
|
46
|
+
queue: timelineItems(last:10, itemTypes:[ADDED_TO_MERGE_QUEUE_EVENT, REMOVED_FROM_MERGE_QUEUE_EVENT]) {
|
|
47
|
+
nodes { __typename ... on AddedToMergeQueueEvent { createdAt } ... on RemovedFromMergeQueueEvent { createdAt reason } } }
|
|
48
|
+
recent: commits(last:5) { nodes { commit { authoredDate committedDate parents { totalCount } author { user { login } } message } } }
|
|
49
|
+
talk: timelineItems(last:10, itemTypes:[ISSUE_COMMENT]) {
|
|
50
|
+
filteredCount nodes { ... on IssueComment { createdAt body author { __typename login } authorAssociation } }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}"""
|
|
56
|
+
|
|
57
|
+
# Without checks/statuses read access the built-in token may not read a commit's check result; ask without it then.
|
|
58
|
+
QUERY_NO_CHECKS = QUERY.replace(" statusCheckRollup { state }", "")
|
|
59
|
+
# Once per run: where code ships (releases, deployments), the default branch and CODEOWNERS (1 point).
|
|
60
|
+
REPO_QUERY = """
|
|
61
|
+
query($owner:String!,$name:String!) {
|
|
62
|
+
rateLimit { cost remaining resetAt }
|
|
63
|
+
repository(owner:$owner,name:$name) {
|
|
64
|
+
defaultBranchRef { name }
|
|
65
|
+
primaryLanguage { name }
|
|
66
|
+
stargazerCount
|
|
67
|
+
isPrivate
|
|
68
|
+
releases(first:100, orderBy:{field:CREATED_AT, direction:DESC}) {
|
|
69
|
+
nodes { tagName createdAt publishedAt isPrerelease isDraft tagCommit { oid } } }
|
|
70
|
+
deployments(first:100, orderBy:{field:CREATED_AT, direction:DESC}) {
|
|
71
|
+
nodes { environment createdAt commitOid latestStatus { state createdAt } } pageInfo { hasNextPage endCursor } }
|
|
72
|
+
co1: object(expression:"HEAD:.github/CODEOWNERS") { ... on Blob { text } }
|
|
73
|
+
co2: object(expression:"HEAD:CODEOWNERS") { ... on Blob { text } }
|
|
74
|
+
co3: object(expression:"HEAD:docs/CODEOWNERS") { ... on Blob { text } }
|
|
75
|
+
}
|
|
76
|
+
}"""
|
|
77
|
+
# Deployments past the newest 100 (previews crowd out production on busy repos), one point a page.
|
|
78
|
+
DEPLOY_PAGE = """
|
|
79
|
+
query($owner:String!,$name:String!,$after:String) {
|
|
80
|
+
rateLimit { cost remaining resetAt }
|
|
81
|
+
repository(owner:$owner,name:$name) {
|
|
82
|
+
deployments(first:100, after:$after, orderBy:{field:CREATED_AT, direction:DESC}) {
|
|
83
|
+
nodes { environment createdAt commitOid latestStatus { state createdAt } } pageInfo { hasNextPage endCursor } }
|
|
84
|
+
}
|
|
85
|
+
}"""
|
|
86
|
+
MAX_DEPLOY_PAGES = 40
|
|
87
|
+
# The commits each production deployment shipped: its history back to the deployment before it, 20 per query.
|
|
88
|
+
RANGE_OID = re.compile(r'object\(oid:"([0-9a-z]+)"\)')
|
|
89
|
+
RANGE_BATCH = 20
|
|
90
|
+
# The built-in token reads deployments only with `deployments: read` (every sandbox run failed without it): ask without them.
|
|
91
|
+
REPO_QUERY_NO_DEPLOYMENTS = REPO_QUERY.replace(""" deployments(first:100, orderBy:{field:CREATED_AT, direction:DESC}) {
|
|
92
|
+
nodes { environment createdAt commitOid latestStatus { state createdAt } } pageInfo { hasNextPage endCursor } }
|
|
93
|
+
""", "")
|
|
94
|
+
# Records saved before file lists, linked issues, conflicts, authored dates, review flags, labels, commit authors and assignees
|
|
95
|
+
# were collected are read again once.
|
|
96
|
+
SCHEMA = 8
|
|
97
|
+
OVERLAP = timedelta(hours=1)
|
|
98
|
+
OPEN_ONLY = ("lbe", "cmt", "asg")
|
|
99
|
+
SIMPLE = {"ReadyForReviewEvent": "ready", "ConvertToDraftEvent": "draft", "ClosedEvent": "closed",
|
|
100
|
+
"ReopenedEvent": "reopened"}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _who(reviewer: dict | None) -> str | None:
|
|
104
|
+
if not reviewer:
|
|
105
|
+
return None
|
|
106
|
+
if reviewer.get("__typename") == "Team":
|
|
107
|
+
return f"team:{reviewer.get('slug')}"
|
|
108
|
+
return reviewer.get("login")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _event(e: dict, pr_author: str | None = None) -> list | None:
|
|
112
|
+
kind = e.get("__typename")
|
|
113
|
+
if kind == "ClosedEvent":
|
|
114
|
+
# Who closed it: an author closing their own PR is not an answer (Community).
|
|
115
|
+
return ["closed", e.get("createdAt"), (e.get("actor") or {}).get("login")]
|
|
116
|
+
if kind in SIMPLE:
|
|
117
|
+
return [SIMPLE[kind], e.get("createdAt")]
|
|
118
|
+
if kind == "ReviewRequestedEvent":
|
|
119
|
+
return ["req", e.get("createdAt"), _who(e.get("requestedReviewer"))]
|
|
120
|
+
if kind == "ReviewRequestRemovedEvent":
|
|
121
|
+
return ["unreq", e.get("createdAt"), _who(e.get("requestedReviewer"))]
|
|
122
|
+
if kind == "PullRequestReview":
|
|
123
|
+
author = e.get("author") or {}
|
|
124
|
+
# Whether the review raised something (line comments, a question, a mention); its text is never kept.
|
|
125
|
+
raised = bool((e.get("comments") or {}).get("totalCount")) or _asks_author(e.get("body"), pr_author)
|
|
126
|
+
return ["review", e.get("submittedAt"), author.get("login"), e.get("state"), e.get("authorAssociation"),
|
|
127
|
+
author.get("__typename"), raised]
|
|
128
|
+
if kind == "MergedEvent":
|
|
129
|
+
return ["merged", e.get("createdAt"), (e.get("actor") or {}).get("login")]
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# Co-author trailers and footers coding assistants add to commits: what a public repo can show about AI help.
|
|
134
|
+
AI_TRAILERS = (("claude", ("noreply@anthropic.com", "co-authored-by: claude", "generated with [claude code]")),
|
|
135
|
+
("copilot", ("copilot@users.noreply.github.com", "co-authored-by: copilot")),
|
|
136
|
+
("cursor", ("cursoragent@cursor.com", "co-authored-by: cursor")),
|
|
137
|
+
("codex", ("co-authored-by: codex", "codex@openai.com")),
|
|
138
|
+
("devin", ("devin-ai-integration",)),
|
|
139
|
+
("aider", ("co-authored-by: aider",)),
|
|
140
|
+
("gemini", ("co-authored-by: gemini",)))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def ai_tools(messages) -> list[str]:
|
|
144
|
+
text = "\n".join(m or "" for m in messages).lower()
|
|
145
|
+
return sorted(name for name, marks in AI_TRAILERS if any(m in text for m in marks))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# Links a coding agent leaves in the PR description it wrote (a Codex task, a Devin session, a Cursor agent).
|
|
149
|
+
BODY_MARKS = (("codex", ("chatgpt.com/codex/tasks/",)), ("devin", ("app.devin.ai/sessions/",)),
|
|
150
|
+
("cursor", ("cursor.com/background-agent", "cursor.com/agents")), ("jules", ("jules.google.com/task",)))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def body_tools(body: str | None) -> list[str]:
|
|
154
|
+
"""AI tools a PR's description names: the commit trailers and footers, or an agent's task link."""
|
|
155
|
+
text = (body or "").lower()
|
|
156
|
+
return sorted(set(ai_tools([text])) | {name for name, marks in BODY_MARKS if any(m in text for m in marks)})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _asks_author(body: str | None, author: str | None) -> bool:
|
|
160
|
+
text = body or ""
|
|
161
|
+
return "?" in text or bool(author and f"@{author.lower()}" in text.lower())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _assignees(marks: list[dict]) -> list[str]:
|
|
165
|
+
# Who is assigned now, replayed from the last assignment events (a separate assignees list costs a point more per page).
|
|
166
|
+
current = []
|
|
167
|
+
for x in sorted((m for m in marks if m and m.get("createdAt")), key=lambda m: m["createdAt"]):
|
|
168
|
+
who = (x.get("assignee") or {}).get("login")
|
|
169
|
+
if who and x.get("__typename") == "AssignedEvent" and who not in current:
|
|
170
|
+
current.append(who)
|
|
171
|
+
elif who and x.get("__typename") == "UnassignedEvent" and who in current:
|
|
172
|
+
current.remove(who)
|
|
173
|
+
return current
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def pr_record(node: dict, now: datetime) -> dict:
|
|
177
|
+
"""One PR as a compact record: its state, size, author, open review requests and timed events."""
|
|
178
|
+
author = node.get("author") or {}
|
|
179
|
+
last = ((node.get("commits") or {}).get("nodes") or [{}])[-1].get("commit") or {}
|
|
180
|
+
events = [ev for e in (node.get("events") or {}).get("nodes") or [] if e and (ev := _event(e, author.get("login"))) and ev[1]]
|
|
181
|
+
# Comment text is only used to tell whether it asks the author something; it is never kept.
|
|
182
|
+
talk = [[c.get("createdAt"), (c.get("author") or {}).get("login"), c.get("authorAssociation"),
|
|
183
|
+
(c.get("author") or {}).get("__typename"), _asks_author(c.get("body"), author.get("login"))]
|
|
184
|
+
for c in (node.get("talk") or {}).get("nodes") or [] if c]
|
|
185
|
+
# The first comments too: pickup ends at the first answer, which the latest ten miss on busy PRs (no text read).
|
|
186
|
+
seen = {(c[0], c[1]) for c in talk}
|
|
187
|
+
talk += [[c.get("createdAt"), (c.get("author") or {}).get("login"), c.get("authorAssociation"),
|
|
188
|
+
(c.get("author") or {}).get("__typename"), False]
|
|
189
|
+
for c in (node.get("early") or {}).get("nodes") or []
|
|
190
|
+
if c and c.get("createdAt") and (c.get("createdAt"), (c.get("author") or {}).get("login")) not in seen]
|
|
191
|
+
first = node.get("first") or {}
|
|
192
|
+
recent = [x.get("commit") or {} for x in (node.get("recent") or {}).get("nodes") or [] if x]
|
|
193
|
+
return {"t": "pr", "n": node["number"], "ti": (node.get("title") or "")[:200], "draft": node.get("isDraft"), "st": node.get("state"),
|
|
194
|
+
"cr": node.get("createdAt"), "up": node.get("updatedAt"), "mg": node.get("mergedAt"), "cl": node.get("closedAt"),
|
|
195
|
+
"add": node.get("additions"), "del": node.get("deletions"), "files": node.get("changedFiles"),
|
|
196
|
+
"au": author.get("login"), "at": author.get("__typename"), "as": node.get("authorAssociation"),
|
|
197
|
+
"base": node.get("baseRefName"), "head": node.get("headRefName"),
|
|
198
|
+
"ho": (node.get("headRepositoryOwner") or {}).get("login"),
|
|
199
|
+
"lc": last.get("committedDate"), "la": last.get("authoredDate"), "mgs": node.get("mergeable"), "ci": (last.get("statusCheckRollup") or {}).get("state"),
|
|
200
|
+
"iss": bool(((node.get("closingIssuesReferences") or {}).get("totalCount") or 0)),
|
|
201
|
+
"paths": [f["path"] for f in (node.get("files") or {}).get("nodes") or [] if f and f.get("path")],
|
|
202
|
+
"rq": [w for r in (node.get("reviewRequests") or {}).get("nodes") or [] if (w := _who((r or {}).get("requestedReviewer")))],
|
|
203
|
+
"ev": sorted(events, key=lambda e: e[1]), "evn": (node.get("events") or {}).get("filteredCount"),
|
|
204
|
+
# When it entered and left a merge queue, and why it left (merge-queue health); only on PRs that used one.
|
|
205
|
+
**({"mq": queue} if (queue := [["add", q.get("createdAt")] if q.get("__typename") == "AddedToMergeQueueEvent"
|
|
206
|
+
else ["rm", q.get("createdAt"), q.get("reason")]
|
|
207
|
+
for q in (node.get("queue") or {}).get("nodes") or [] if q and q.get("createdAt")]) else {}),
|
|
208
|
+
"cm": sorted((c for c in talk if c[0]), key=lambda c: c[0]),
|
|
209
|
+
"cmn": (node.get("talk") or {}).get("filteredCount"),
|
|
210
|
+
"fa": ((((first.get("nodes") or [{}])[0] or {}).get("commit") or {}).get("authoredDate")),
|
|
211
|
+
"nc": first.get("totalCount"), "mc": (node.get("mergeCommit") or {}).get("oid"),
|
|
212
|
+
# Only the tools named are kept, never the description itself.
|
|
213
|
+
"ai": sorted(set(ai_tools(c.get("message") for c in recent)) | set(body_tools(node.get("body")))),
|
|
214
|
+
"lb": [x["name"] for x in (node.get("labels") or {}).get("nodes") or [] if x and x.get("name")],
|
|
215
|
+
"asg": _assignees((node.get("marks") or {}).get("nodes") or []),
|
|
216
|
+
# When labels were added (+) or removed (-), and who made the last commits (a maintainer's merge of main isn't the author's work).
|
|
217
|
+
"lbe": [[x.get("createdAt"), "+" if x.get("__typename") == "LabeledEvent" else "-", (x.get("label") or {}).get("name")]
|
|
218
|
+
for x in (node.get("marks") or {}).get("nodes") or []
|
|
219
|
+
if x and x.get("createdAt") and x.get("__typename") in ("LabeledEvent", "UnlabeledEvent")],
|
|
220
|
+
"cmt": [[c.get("authoredDate"), c.get("committedDate"), ((c.get("author") or {}).get("user") or {}).get("login"),
|
|
221
|
+
((c.get("parents") or {}).get("totalCount") or 0) > 1]
|
|
222
|
+
for c in (x.get("commit") or {} for x in (node.get("recent") or {}).get("nodes") or [] if x)],
|
|
223
|
+
"f": now.strftime("%Y-%m-%dT%H:%M:%SZ")}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def collect(gql, store: FlowStore, owner: str, name: str, now: datetime, months: int = 12, page: int = 25,
|
|
227
|
+
max_pages: int = 500, log=None) -> dict:
|
|
228
|
+
"""Read PRs updated since the last run (or the last `months` on the first), newest first."""
|
|
229
|
+
state = store.state()
|
|
230
|
+
synced = state.get("synced_up_to") if state.get("schema", 1) >= SCHEMA else None
|
|
231
|
+
floor = (datetime.fromisoformat(synced.replace("Z", "+00:00")) - OVERLAP if synced
|
|
232
|
+
else now - timedelta(days=30 * months)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
233
|
+
opened, closed = store.open(), defaultdict(list)
|
|
234
|
+
cursor, newest, count, cost, complete, pages = None, None, 0, 0, True, 0
|
|
235
|
+
query, no_checks = QUERY, False
|
|
236
|
+
while pages < max_pages:
|
|
237
|
+
try:
|
|
238
|
+
result = gql.query(query, {"owner": owner, "name": name, "cursor": cursor, "n": page})
|
|
239
|
+
except RateLimited:
|
|
240
|
+
complete = False
|
|
241
|
+
break
|
|
242
|
+
except GraphQLError as exc:
|
|
243
|
+
if "not accessible" in str(exc).lower() and query is QUERY:
|
|
244
|
+
query, no_checks = QUERY_NO_CHECKS, True
|
|
245
|
+
continue
|
|
246
|
+
# A page GitHub finds too heavy is asked again in smaller pieces.
|
|
247
|
+
if too_heavy(exc) and page > 5:
|
|
248
|
+
page = max(5, page // 2)
|
|
249
|
+
continue
|
|
250
|
+
raise
|
|
251
|
+
pages += 1
|
|
252
|
+
cost += result.cost
|
|
253
|
+
block = result.data["repository"]["pullRequests"]
|
|
254
|
+
stop = False
|
|
255
|
+
for node in block["nodes"]:
|
|
256
|
+
if not node:
|
|
257
|
+
continue
|
|
258
|
+
if node["updatedAt"] < floor:
|
|
259
|
+
stop = True
|
|
260
|
+
break
|
|
261
|
+
newest = newest or node["updatedAt"]
|
|
262
|
+
rec = pr_record(node, now)
|
|
263
|
+
count += 1
|
|
264
|
+
if rec["st"] == "OPEN":
|
|
265
|
+
opened[rec["n"]] = rec
|
|
266
|
+
else:
|
|
267
|
+
opened.pop(rec["n"], None)
|
|
268
|
+
# Labels, label times and commit authors only matter while a PR is open (history stays ~20% smaller).
|
|
269
|
+
closed[(rec["cl"] or rec["up"])[:10]].append({k: v for k, v in rec.items() if k not in OPEN_ONLY})
|
|
270
|
+
if log and pages % 20 == 0:
|
|
271
|
+
log(f"{count} PRs read so far ({cost} points)")
|
|
272
|
+
if stop or not block["pageInfo"]["hasNextPage"]:
|
|
273
|
+
break
|
|
274
|
+
cursor = block["pageInfo"]["endCursor"]
|
|
275
|
+
else:
|
|
276
|
+
complete = False
|
|
277
|
+
for day, recs in closed.items():
|
|
278
|
+
store.put(recs, day)
|
|
279
|
+
store.save_open(opened)
|
|
280
|
+
if complete:
|
|
281
|
+
state["synced_up_to"] = max(filter(None, [newest, synced]), default=None)
|
|
282
|
+
state["schema"] = SCHEMA
|
|
283
|
+
info = repo_info(gql, owner, name, (now - timedelta(days=30 * months)).strftime("%Y-%m-%dT%H:%M:%SZ"), store.repo())
|
|
284
|
+
# A refused repo query keeps the details an earlier run saved.
|
|
285
|
+
if "default_branch" in info:
|
|
286
|
+
store.save_repo(info)
|
|
287
|
+
# Which failing checks are required, and whether CI waits for approval (open PRs only).
|
|
288
|
+
collect_checks(gql, store, owner, name)
|
|
289
|
+
state["last_collect"] = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
290
|
+
store.save_state(state)
|
|
291
|
+
store.compress(now.date().isoformat())
|
|
292
|
+
return {"prs": count, "points": cost, "complete": complete, "no_checks": no_checks,
|
|
293
|
+
"no_deployments": bool(info.get("no_deployments"))}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def repo_info(gql, owner: str, name: str, since: str | None = None, saved: dict | None = None) -> dict:
|
|
297
|
+
"""Releases (drafts left out), deployments, the default branch and CODEOWNERS text. Repo details are extras: a
|
|
298
|
+
refused query is asked again without deployments, and if that fails too the run goes on without them."""
|
|
299
|
+
no_deployments = False
|
|
300
|
+
try:
|
|
301
|
+
repo = gql.query(REPO_QUERY, {"owner": owner, "name": name}).data["repository"]
|
|
302
|
+
except GraphQLError:
|
|
303
|
+
no_deployments = True
|
|
304
|
+
try:
|
|
305
|
+
repo = gql.query(REPO_QUERY_NO_DEPLOYMENTS, {"owner": owner, "name": name}).data["repository"]
|
|
306
|
+
except GraphQLError:
|
|
307
|
+
return {"no_deployments": True}
|
|
308
|
+
complete = False
|
|
309
|
+
if not no_deployments and since and repo.get("deployments"):
|
|
310
|
+
more, complete = _older_deployments(gql, owner, name, repo["deployments"], since, saved)
|
|
311
|
+
repo["deployments"]["nodes"] += more
|
|
312
|
+
info = _repo_fields(repo)
|
|
313
|
+
if since and not no_deployments:
|
|
314
|
+
info["deployments"] = merge_history((saved or {}).get("deployments") or [], info["deployments"], since)
|
|
315
|
+
# How far back the saved deployments are complete; a capped backfill goes on next run.
|
|
316
|
+
if complete or ((saved or {}).get("deployments_from") or "9") <= since:
|
|
317
|
+
info["deployments_from"] = since
|
|
318
|
+
if since and not no_deployments:
|
|
319
|
+
info["ranges"] = deploy_ranges(gql, owner, name, info["deployments"], (saved or {}).get("ranges") or {})
|
|
320
|
+
if since:
|
|
321
|
+
info["releases"] = merge_history((saved or {}).get("releases") or [], info["releases"], since, ("tag",))
|
|
322
|
+
return {**info, "no_deployments": True} if no_deployments else info
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _older_deployments(gql, owner: str, name: str, block: dict, since: str, saved: dict | None) -> list[dict]:
|
|
326
|
+
"""Pages past the newest 100, back to the window or to two days before what an earlier run saved."""
|
|
327
|
+
known = [d["at"] for d in (saved or {}).get("deployments") or [] if d.get("at")]
|
|
328
|
+
# Saved history counts only once it is complete back to the window (older saves kept just the newest 100).
|
|
329
|
+
complete = known and ((saved or {}).get("deployments_from") or "9") <= since
|
|
330
|
+
stop = max([since] + ([(datetime.fromisoformat(max(known).replace("Z", "+00:00")) - timedelta(days=2))
|
|
331
|
+
.strftime("%Y-%m-%dT%H:%M:%SZ")] if complete else []))
|
|
332
|
+
out, pages = [], 0
|
|
333
|
+
while True:
|
|
334
|
+
oldest = min((n["createdAt"] for n in block.get("nodes") or [] if n and n.get("createdAt")), default="")
|
|
335
|
+
if oldest < stop or not (block.get("pageInfo") or {}).get("hasNextPage"):
|
|
336
|
+
block["complete"] = True
|
|
337
|
+
break
|
|
338
|
+
if pages >= MAX_DEPLOY_PAGES:
|
|
339
|
+
break
|
|
340
|
+
try:
|
|
341
|
+
block = gql.query(DEPLOY_PAGE, {"owner": owner, "name": name, "after": block["pageInfo"]["endCursor"]}
|
|
342
|
+
).data["repository"]["deployments"]
|
|
343
|
+
except GraphQLError:
|
|
344
|
+
break
|
|
345
|
+
out += [n for n in block.get("nodes") or [] if n and (n.get("createdAt") or "") >= since]
|
|
346
|
+
pages += 1
|
|
347
|
+
return out, bool(block.get("complete"))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def deploy_ranges(gql, owner: str, name: str, deployments: list[dict], saved: dict) -> dict:
|
|
351
|
+
"""{"prev..sha": {"shas": [...], "complete": bool}} for each pair of successive production deployments: the
|
|
352
|
+
commits the newer one shipped, newest first (incomplete when the older one is over 100 commits back). Pairs read
|
|
353
|
+
before are kept, never read again."""
|
|
354
|
+
from stackvitals.core.config import DELIVERY
|
|
355
|
+
from stackvitals.core.delivery import is_production
|
|
356
|
+
prod = {x.lower() for x in DELIVERY["production"]}
|
|
357
|
+
seq = [d["sha"] for d in sorted((d for d in deployments if d.get("sha") and RANGE_OID.fullmatch(f'object(oid:"{d["sha"]}")')
|
|
358
|
+
and d.get("state") in ("SUCCESS", "INACTIVE") and is_production(d.get("env"), prod)),
|
|
359
|
+
key=lambda d: d.get("done") or d.get("at") or "")]
|
|
360
|
+
pairs = [(a, b) for a, b in zip(seq, seq[1:]) if a != b]
|
|
361
|
+
out = {f"{a}..{b}": saved[f"{a}..{b}"] for a, b in pairs if f"{a}..{b}" in saved}
|
|
362
|
+
todo = [(a, b) for a, b in pairs if f"{a}..{b}" not in out]
|
|
363
|
+
for i in range(0, len(todo), RANGE_BATCH):
|
|
364
|
+
batch = todo[i:i + RANGE_BATCH]
|
|
365
|
+
parts = " ".join(f'd{k}: object(oid:"{b}") {{ ... on Commit {{ history(first:100) {{ nodes {{ oid }} }} }} }}'
|
|
366
|
+
for k, (_a, b) in enumerate(batch))
|
|
367
|
+
q = ("query($owner:String!,$name:String!) { rateLimit { cost remaining resetAt } "
|
|
368
|
+
f"repository(owner:$owner,name:$name) {{ {parts} }} }}")
|
|
369
|
+
try:
|
|
370
|
+
repo = gql.query(q, {"owner": owner, "name": name}).data["repository"]
|
|
371
|
+
except GraphQLError:
|
|
372
|
+
break
|
|
373
|
+
for k, (a, b) in enumerate(batch):
|
|
374
|
+
nodes = [n["oid"] for n in ((repo.get(f"d{k}") or {}).get("history") or {}).get("nodes") or [] if n]
|
|
375
|
+
shas = []
|
|
376
|
+
for oid in nodes:
|
|
377
|
+
if oid == a:
|
|
378
|
+
break
|
|
379
|
+
shas.append(oid)
|
|
380
|
+
out[f"{a}..{b}"] = {"shas": shas, "complete": a in nodes}
|
|
381
|
+
return out
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def merge_history(old: list[dict], new: list[dict], keep_since: str, key=("env", "at", "sha")) -> list[dict]:
|
|
385
|
+
"""Earlier runs' deployments (or releases) with this run's, newest first; one read again keeps its new state."""
|
|
386
|
+
seen = {tuple(d.get(k) for k in key): d for d in old if (d.get("at") or "") >= keep_since}
|
|
387
|
+
seen.update({tuple(d.get(k) for k in key): d for d in new if (d.get("at") or "") >= keep_since})
|
|
388
|
+
return sorted(seen.values(), key=lambda d: d.get("at") or "", reverse=True)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _repo_fields(repo: dict) -> dict:
|
|
392
|
+
codeowners = next((b["text"] for b in (repo.get("co1"), repo.get("co2"), repo.get("co3")) if b and b.get("text")), None)
|
|
393
|
+
return {"default_branch": (repo.get("defaultBranchRef") or {}).get("name"), "codeowners": codeowners,
|
|
394
|
+
"language": (repo.get("primaryLanguage") or {}).get("name"), "stars": repo.get("stargazerCount"),
|
|
395
|
+
"private": bool(repo.get("isPrivate")),
|
|
396
|
+
"releases": [{"tag": r["tagName"], "at": r.get("publishedAt") or r.get("createdAt"), "pre": bool(r.get("isPrerelease")),
|
|
397
|
+
"sha": (r.get("tagCommit") or {}).get("oid")}
|
|
398
|
+
for r in (repo.get("releases") or {}).get("nodes") or [] if r and not r.get("isDraft")],
|
|
399
|
+
"deployments": [{"env": d.get("environment"), "at": d.get("createdAt"), "state": (d.get("latestStatus") or {}).get("state"),
|
|
400
|
+
"sha": d.get("commitOid"), "done": (d.get("latestStatus") or {}).get("createdAt")}
|
|
401
|
+
for d in (repo.get("deployments") or {}).get("nodes") or [] if d]}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# A cancelled run (often a duplicate replaced by a newer one) is not a failure.
|
|
405
|
+
FAILED_STATES = {"FAILURE", "ERROR", "TIMED_OUT", "STARTUP_FAILURE"}
|
|
406
|
+
PASSED_STATES = {"SUCCESS", "NEUTRAL", "SKIPPED"}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _checks_query(numbers: list[int]) -> str:
|
|
410
|
+
parts = "\n".join(
|
|
411
|
+
f" p{n}: pullRequest(number:{n}) {{ mergeable commits(last:1) {{ nodes {{ commit {{\n"
|
|
412
|
+
" checkSuites(first:15) { nodes { conclusion app { slug } } }\n"
|
|
413
|
+
" statusCheckRollup { contexts(first:60) { nodes { __typename\n"
|
|
414
|
+
f" ... on CheckRun {{ name conclusion isRequired(pullRequestNumber:{n}) }}\n"
|
|
415
|
+
f" ... on StatusContext {{ context state isRequired(pullRequestNumber:{n}) }} }} }} }} }} }} }} }}"
|
|
416
|
+
for n in numbers)
|
|
417
|
+
return (f"query($owner:String!,$name:String!) {{ rateLimit {{ cost remaining resetAt }} "
|
|
418
|
+
f"repository(owner:$owner,name:$name) {{\n{parts}\n}} }}")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def collect_checks(gql, store: FlowStore, owner: str, name: str, batch: int = 20) -> int:
|
|
422
|
+
"""For open, ready PRs: which checks fail and whether each is required to merge, and whether CI waits for approval."""
|
|
423
|
+
opened = store.open()
|
|
424
|
+
todo = [n for n, p in opened.items() if not p.get("draft")]
|
|
425
|
+
for i in range(0, len(todo), batch):
|
|
426
|
+
chunk = todo[i:i + batch]
|
|
427
|
+
try:
|
|
428
|
+
repo = gql.query(_checks_query(chunk), {"owner": owner, "name": name}).data["repository"]
|
|
429
|
+
except GraphQLError:
|
|
430
|
+
# Keep the batches already read; the next run reads the rest.
|
|
431
|
+
break
|
|
432
|
+
for n in chunk:
|
|
433
|
+
pull = repo.get(f"p{n}") or {}
|
|
434
|
+
# A push to the base branch can create conflicts without touching the PR, so this is re-read every run.
|
|
435
|
+
if pull.get("mergeable"):
|
|
436
|
+
opened[n]["mgs"] = pull["mergeable"]
|
|
437
|
+
commit = (((pull.get("commits") or {}).get("nodes") or [{}])[-1] or {}).get("commit") or {}
|
|
438
|
+
contexts = ((commit.get("statusCheckRollup") or {}).get("contexts") or {}).get("nodes") or []
|
|
439
|
+
passed = {c.get("name") or c.get("context") for c in contexts if c and (c.get("conclusion") or c.get("state")) in PASSED_STATES}
|
|
440
|
+
# A check that also passed on the same commit (a re-run) is not failing.
|
|
441
|
+
failing = [[c.get("name") or c.get("context"), bool(c.get("isRequired"))] for c in contexts
|
|
442
|
+
if c and (c.get("conclusion") or c.get("state")) in FAILED_STATES
|
|
443
|
+
and (c.get("name") or c.get("context")) not in passed]
|
|
444
|
+
suites = (commit.get("checkSuites") or {}).get("nodes") or []
|
|
445
|
+
opened[n]["chk"] = {"failing": failing, "ran": sorted({c.get("name") or c.get("context") for c in contexts if c} - {None}),
|
|
446
|
+
# Only GitHub Actions runs waiting for approval are the maintainers' move (not a CLA bot).
|
|
447
|
+
"awaiting": any(x and x.get("conclusion") == "ACTION_REQUIRED"
|
|
448
|
+
and (x.get("app") or {}).get("slug") == "github-actions" for x in suites),
|
|
449
|
+
"required_any": any(c and c.get("isRequired") for c in contexts)}
|
|
450
|
+
store.save_open(opened)
|
|
451
|
+
return len(todo)
|