mcp-github-crunchtools 1.4.0__tar.gz → 1.5.0__tar.gz

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.
Files changed (54) hide show
  1. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/CHANGELOG.md +18 -0
  2. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/Containerfile +1 -1
  3. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/PKG-INFO +20 -10
  4. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/README.md +19 -9
  5. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/pyproject.toml +1 -1
  6. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/server.json +2 -2
  7. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/__init__.py +1 -1
  8. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/errors.py +1 -0
  9. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/server.py +7 -3
  10. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/tools/pull_requests.py +175 -28
  11. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/tests/test_merge_gate.py +288 -4
  12. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/uv.lock +1 -1
  13. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.gatehouse-ignore +0 -0
  14. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  15. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  16. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  17. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.github/dependabot.yml +0 -0
  18. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.github/workflows/ashigaru.yml +0 -0
  19. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.github/workflows/ci.yml +0 -0
  20. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.github/workflows/constitution.yml +0 -0
  21. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.github/workflows/container.yml +0 -0
  22. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.github/workflows/dependabot-automerge.yml +0 -0
  23. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.github/workflows/gatehouse-retriage.yml +0 -0
  24. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.github/workflows/gatehouse.yml +0 -0
  25. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.github/workflows/publish.yml +0 -0
  26. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.github/workflows/security.yml +0 -0
  27. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.gitignore +0 -0
  28. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.pre-commit-config.yaml +0 -0
  29. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.specify/memory/constitution.md +0 -0
  30. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.specify/specs/000-baseline/spec.md +0 -0
  31. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.specify/specs/001-pr-merge-logs-release/spec.md +0 -0
  32. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.specify/specs/002-request-token-merge-gate/spec.md +0 -0
  33. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.specify/templates/plan-template.md +0 -0
  34. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/.specify/templates/spec-template.md +0 -0
  35. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/CLAUDE.md +0 -0
  36. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/LICENSE +0 -0
  37. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/SECURITY.md +0 -0
  38. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/gourmand-exceptions.toml +0 -0
  39. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/gourmand.toml +0 -0
  40. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/__main__.py +0 -0
  41. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/client.py +0 -0
  42. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/config.py +0 -0
  43. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/models.py +0 -0
  44. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/tools/__init__.py +0 -0
  45. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/tools/actions.py +0 -0
  46. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/tools/files.py +0 -0
  47. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/tools/issues.py +0 -0
  48. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/tools/releases.py +0 -0
  49. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/src/mcp_github_crunchtools/tools/search.py +0 -0
  50. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/tests/__init__.py +0 -0
  51. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/tests/conftest.py +0 -0
  52. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/tests/test_request_token.py +0 -0
  53. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/tests/test_tools.py +0 -0
  54. {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.5.0}/tests/test_validation.py +0 -0
@@ -6,6 +6,24 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.5.0] - 2026-10-20
10
+
11
+ ### Added
12
+
13
+ - `merge_pull_request_tool` and `get_pull_request_checks_tool` work with a
14
+ fine-grained token. GitHub cannot grant one the Checks permission, so
15
+ check-runs answers 403; the tools then read the head commit's Actions jobs
16
+ (Actions: read), each of which is a check run with the same id and name.
17
+ Only Actions-reported checks are visible that way, so the merge gate
18
+ refuses a required check that names another app, or that accepts any app
19
+ and has no commit status. Both results carry `checks_source`.
20
+
21
+ ### Fixed
22
+
23
+ - `get_pull_request_checks_tool` reads every page of check runs, not just the
24
+ first, up to the merge gate's page limit; past it the verdict says
25
+ `truncated` and is not `ready_to_merge`.
26
+
9
27
  ## [1.4.0] - 2026-10-17
10
28
 
11
29
  ### Added
@@ -27,7 +27,7 @@ FROM quay.io/hummingbird/python:latest
27
27
 
28
28
  # Labels for container metadata
29
29
  LABEL name="mcp-github-crunchtools" \
30
- version="1.4.0" \
30
+ version="1.5.0" \
31
31
  summary="Secure MCP server for GitHub issues, pull requests, Actions, releases, files, and search" \
32
32
  description="A security-focused MCP server for GitHub built on Red Hat UBI" \
33
33
  maintainer="crunchtools.com" \
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-github-crunchtools
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Summary: Secure MCP server for GitHub issues, pull requests, Actions, releases, files, and search
5
5
  Author: crunchtools.com
6
6
  License-Expression: AGPL-3.0-or-later
@@ -82,12 +82,12 @@ permission the tool needs (see [Token permissions](#token-permissions)).
82
82
  | `list_pull_requests_tool` | List pull requests for a repository | Pull requests: read |
83
83
  | `get_pull_request_tool` | Get a single pull request by number | Pull requests: read |
84
84
  | `get_pull_request_diff_tool` | Get the unified diff for a pull request | Pull requests: read |
85
- | `get_pull_request_checks_tool` | CI verdict from check-runs and commit statuses; skipped is not failed | Pull requests: read, Commit statuses: read (and see the note under Token permissions) |
85
+ | `get_pull_request_checks_tool` | CI verdict from check-runs and commit statuses; skipped is not failed | Pull requests: read, Commit statuses: read, Actions: read (and see the note under Token permissions) |
86
86
  | `list_pull_request_comments_tool` | Conversation comments, diff comments and reviews in one list, each tagged with its kind | Pull requests: read |
87
87
  | `create_pull_request_tool` | Open a pull request, optionally as a draft (*write*) | Pull requests: write |
88
88
  | `update_pull_request_tool` | Edit, close or reopen a pull request; does not merge (*write*) | Pull requests: write |
89
89
  | `reply_to_review_comment_tool` | Reply in the thread of a diff comment (*write*) | Pull requests: write |
90
- | `merge_pull_request_tool` | Merge, squash or rebase a pull request, only once the base branch's required checks are green on its head (see [The merge gate](#the-merge-gate)) (*write*) | Contents: write; Pull requests and Commit statuses: read |
90
+ | `merge_pull_request_tool` | Merge, squash or rebase a pull request, only once the base branch's required checks are green on its head (see [The merge gate](#the-merge-gate)) (*write*) | Contents: write; Pull requests, Commit statuses and Actions: read |
91
91
 
92
92
  ### Actions (6 tools)
93
93
 
@@ -224,14 +224,23 @@ For a fine-grained personal access token, the repository permissions are:
224
224
  | Contents | `get_file_content_tool`, `list_repo_tree_tool` | `merge_pull_request_tool`, `create_release_tool` |
225
225
  | Issues | `list_issues_tool`, `get_issue_tool`, `list_issue_comments_tool` | `create_issue_tool`, `update_issue_tool`, `create_issue_comment_tool` |
226
226
  | Pull requests | `merge_pull_request_tool`, `list_pull_requests_tool`, `get_pull_request_tool`, `get_pull_request_diff_tool`, `get_pull_request_checks_tool`, `list_pull_request_comments_tool` | `create_pull_request_tool`, `update_pull_request_tool`, `reply_to_review_comment_tool`, `create_issue_comment_tool` on a PR |
227
- | Actions | `list_workflow_runs_tool`, `get_workflow_run_tool`, `get_failed_job_logs_tool` | `trigger_workflow_tool`, `rerun_workflow_run_tool`, `rerun_failed_jobs_tool` |
227
+ | Actions | `list_workflow_runs_tool`, `get_workflow_run_tool`, `get_failed_job_logs_tool`, `get_pull_request_checks_tool`, `merge_pull_request_tool` | `trigger_workflow_tool`, `rerun_workflow_run_tool`, `rerun_failed_jobs_tool` |
228
228
  | Commit statuses | `get_pull_request_checks_tool`, `merge_pull_request_tool` | — |
229
229
 
230
- `get_pull_request_checks_tool` and `merge_pull_request_tool` read check-runs, and GitHub's permission table
231
- for fine-grained tokens lists no permission for that endpoint. It is readable
232
- on public repositories; on a private repository use a classic token or a
233
- GitHub App token. The search tools likewise have no permission of their own:
234
- they return what the token can already read.
230
+ `get_pull_request_checks_tool` and `merge_pull_request_tool` read check-runs,
231
+ and GitHub's permission table for fine-grained tokens has no permission for
232
+ that endpoint: a fine-grained token gets 403 on a private repository. Both
233
+ tools then read the head commit's GitHub Actions jobs instead (Actions: read).
234
+ Every Actions job is a check run with the same id and name, so the verdict
235
+ and the merge gate are the same for checks GitHub Actions reports. Checks from
236
+ any other app are not visible that way, so the merge gate refuses a required
237
+ check that names another app, or that accepts any app and has no commit
238
+ status, as missing. `checks_source` in each result says which was read, and
239
+ the checks verdict then carries a `note` saying other apps' checks are not
240
+ visible. Only the newest run of each workflow and event is read, and a commit
241
+ with runs of more than 30 workflows is not judged.
242
+
243
+ The search tools have no permission of their own: they return what the token can already read.
235
244
 
236
245
  Three things worth knowing before granting write:
237
246
 
@@ -266,7 +275,8 @@ merges it:
266
275
  4. requires, for every required check on the head commit, a completed check
267
276
  run of that name concluding `success`, `skipped` or `neutral` (the most
268
277
  recent run of the name decides), or a commit status of `success`. A rule
269
- that names an app accepts only that app's check runs. A missing, pending
278
+ that names an app accepts only that app's check runs. A token that cannot
279
+ read check-runs is judged on the Actions jobs (see above). A missing, pending
270
280
  or failed check refuses the merge, and the error lists each one and why;
271
281
  5. reads the pull request again and refuses if its head or base changed
272
282
  meanwhile (GitHub's merge call pins the head but not the base), then
@@ -56,12 +56,12 @@ permission the tool needs (see [Token permissions](#token-permissions)).
56
56
  | `list_pull_requests_tool` | List pull requests for a repository | Pull requests: read |
57
57
  | `get_pull_request_tool` | Get a single pull request by number | Pull requests: read |
58
58
  | `get_pull_request_diff_tool` | Get the unified diff for a pull request | Pull requests: read |
59
- | `get_pull_request_checks_tool` | CI verdict from check-runs and commit statuses; skipped is not failed | Pull requests: read, Commit statuses: read (and see the note under Token permissions) |
59
+ | `get_pull_request_checks_tool` | CI verdict from check-runs and commit statuses; skipped is not failed | Pull requests: read, Commit statuses: read, Actions: read (and see the note under Token permissions) |
60
60
  | `list_pull_request_comments_tool` | Conversation comments, diff comments and reviews in one list, each tagged with its kind | Pull requests: read |
61
61
  | `create_pull_request_tool` | Open a pull request, optionally as a draft (*write*) | Pull requests: write |
62
62
  | `update_pull_request_tool` | Edit, close or reopen a pull request; does not merge (*write*) | Pull requests: write |
63
63
  | `reply_to_review_comment_tool` | Reply in the thread of a diff comment (*write*) | Pull requests: write |
64
- | `merge_pull_request_tool` | Merge, squash or rebase a pull request, only once the base branch's required checks are green on its head (see [The merge gate](#the-merge-gate)) (*write*) | Contents: write; Pull requests and Commit statuses: read |
64
+ | `merge_pull_request_tool` | Merge, squash or rebase a pull request, only once the base branch's required checks are green on its head (see [The merge gate](#the-merge-gate)) (*write*) | Contents: write; Pull requests, Commit statuses and Actions: read |
65
65
 
66
66
  ### Actions (6 tools)
67
67
 
@@ -198,14 +198,23 @@ For a fine-grained personal access token, the repository permissions are:
198
198
  | Contents | `get_file_content_tool`, `list_repo_tree_tool` | `merge_pull_request_tool`, `create_release_tool` |
199
199
  | Issues | `list_issues_tool`, `get_issue_tool`, `list_issue_comments_tool` | `create_issue_tool`, `update_issue_tool`, `create_issue_comment_tool` |
200
200
  | Pull requests | `merge_pull_request_tool`, `list_pull_requests_tool`, `get_pull_request_tool`, `get_pull_request_diff_tool`, `get_pull_request_checks_tool`, `list_pull_request_comments_tool` | `create_pull_request_tool`, `update_pull_request_tool`, `reply_to_review_comment_tool`, `create_issue_comment_tool` on a PR |
201
- | Actions | `list_workflow_runs_tool`, `get_workflow_run_tool`, `get_failed_job_logs_tool` | `trigger_workflow_tool`, `rerun_workflow_run_tool`, `rerun_failed_jobs_tool` |
201
+ | Actions | `list_workflow_runs_tool`, `get_workflow_run_tool`, `get_failed_job_logs_tool`, `get_pull_request_checks_tool`, `merge_pull_request_tool` | `trigger_workflow_tool`, `rerun_workflow_run_tool`, `rerun_failed_jobs_tool` |
202
202
  | Commit statuses | `get_pull_request_checks_tool`, `merge_pull_request_tool` | — |
203
203
 
204
- `get_pull_request_checks_tool` and `merge_pull_request_tool` read check-runs, and GitHub's permission table
205
- for fine-grained tokens lists no permission for that endpoint. It is readable
206
- on public repositories; on a private repository use a classic token or a
207
- GitHub App token. The search tools likewise have no permission of their own:
208
- they return what the token can already read.
204
+ `get_pull_request_checks_tool` and `merge_pull_request_tool` read check-runs,
205
+ and GitHub's permission table for fine-grained tokens has no permission for
206
+ that endpoint: a fine-grained token gets 403 on a private repository. Both
207
+ tools then read the head commit's GitHub Actions jobs instead (Actions: read).
208
+ Every Actions job is a check run with the same id and name, so the verdict
209
+ and the merge gate are the same for checks GitHub Actions reports. Checks from
210
+ any other app are not visible that way, so the merge gate refuses a required
211
+ check that names another app, or that accepts any app and has no commit
212
+ status, as missing. `checks_source` in each result says which was read, and
213
+ the checks verdict then carries a `note` saying other apps' checks are not
214
+ visible. Only the newest run of each workflow and event is read, and a commit
215
+ with runs of more than 30 workflows is not judged.
216
+
217
+ The search tools have no permission of their own: they return what the token can already read.
209
218
 
210
219
  Three things worth knowing before granting write:
211
220
 
@@ -240,7 +249,8 @@ merges it:
240
249
  4. requires, for every required check on the head commit, a completed check
241
250
  run of that name concluding `success`, `skipped` or `neutral` (the most
242
251
  recent run of the name decides), or a commit status of `success`. A rule
243
- that names an app accepts only that app's check runs. A missing, pending
252
+ that names an app accepts only that app's check runs. A token that cannot
253
+ read check-runs is judged on the Actions jobs (see above). A missing, pending
244
254
  or failed check refuses the merge, and the error lists each one and why;
245
255
  5. reads the pull request again and refuses if its head or base changed
246
256
  meanwhile (GitHub's merge call pins the head but not the base), then
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-github-crunchtools"
3
- version = "1.4.0"
3
+ version = "1.5.0"
4
4
  description = "Secure MCP server for GitHub issues, pull requests, Actions, releases, files, and search"
5
5
  requires-python = ">=3.10"
6
6
  license = "AGPL-3.0-or-later"
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/crunchtools/mcp-github",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.4.0",
10
+ "version": "1.5.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "mcp-github-crunchtools",
15
- "version": "1.4.0",
15
+ "version": "1.5.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -29,7 +29,7 @@ import argparse
29
29
 
30
30
  from .server import mcp
31
31
 
32
- __version__ = "1.4.0"
32
+ __version__ = "1.5.0"
33
33
  __all__ = ["main", "mcp"]
34
34
 
35
35
 
@@ -44,6 +44,7 @@ class GitHubApiError(UserError):
44
44
 
45
45
  def __init__(self, code: int, message: str) -> None:
46
46
  super().__init__(f"GitHub API error {code}: {scrub_tokens(message)}")
47
+ self.status_code = code
47
48
 
48
49
 
49
50
  class NotFoundError(UserError):
@@ -46,7 +46,7 @@ READ_ONLY = {"readOnlyHint": True}
46
46
 
47
47
  mcp = FastMCP(
48
48
  name="mcp-github",
49
- version="1.4.0",
49
+ version="1.5.0",
50
50
  instructions=(
51
51
  "Secure MCP server for GitHub repositories: issues, pull requests "
52
52
  "(open, review discussion, diffs, CI checks, merge), Actions runs and "
@@ -305,7 +305,9 @@ async def get_pull_request_checks_tool(
305
305
  failing, pending, or skipped. SKIPPED checks are NOT failures. Use the
306
306
  returned ``ready_to_merge`` boolean as the signal for whether the PR is
307
307
  clear to merge (True only when nothing is failing or pending and the PR
308
- is not known-unmergeable).
308
+ is not known-unmergeable). A token that cannot read check-runs (any
309
+ fine-grained token) gets the Actions jobs instead; ``checks_source`` says
310
+ which, and only Actions-reported checks are then visible.
309
311
 
310
312
  Args:
311
313
  repo: Repository name
@@ -407,7 +409,9 @@ async def merge_pull_request_tool(
407
409
  head; when branch protection cannot be read; and when the base branch
408
410
  requires no checks at all, unless ``allow_no_required_checks`` is True.
409
411
  Passing check runs conclude success, skipped or neutral. The merge is
410
- pinned to the head SHA that was checked.
412
+ pinned to the head SHA that was checked. A token that cannot read
413
+ check-runs (any fine-grained token) is judged on the Actions jobs, and a
414
+ required check not reported by GitHub Actions is refused as missing.
411
415
 
412
416
  Args:
413
417
  repo: Repository name
@@ -4,6 +4,8 @@ Tools for listing, fetching, diffing, checking, opening, merging, and
4
4
  discussing GitHub pull requests.
5
5
  """
6
6
 
7
+ import asyncio
8
+ from collections.abc import Callable
7
9
  from typing import Any
8
10
  from urllib.parse import quote
9
11
 
@@ -30,6 +32,7 @@ from ..models import (
30
32
  validate_positive_int,
31
33
  validate_ref,
32
34
  )
35
+ from .actions import MAX_RUN_JOBS
33
36
 
34
37
  DIFF_ACCEPT = "application/vnd.github.diff"
35
38
 
@@ -45,6 +48,26 @@ MERGE_GATE_MAX_PAGES = 10
45
48
  # Check-run conclusions that satisfy a required check.
46
49
  PASSING_CONCLUSIONS = frozenset({"success", "skipped", "neutral"})
47
50
 
51
+ # The GitHub Actions app. Every Actions job is a check run reported by it,
52
+ # with the job's id and name.
53
+ ACTIONS_APP_ID = 15368
54
+
55
+ # Where the head commit's check runs were read from. A fine-grained token
56
+ # cannot be granted Checks, so the check-runs endpoint answers it 403; the
57
+ # Actions API (Actions: read) then stands in, seeing only Actions jobs.
58
+ # How many workflow runs' jobs the Actions fallback reads at once, and how
59
+ # many runs (one per workflow and event) it reads at all.
60
+ ACTIONS_JOB_READS_AT_ONCE = 4
61
+ ACTIONS_MAX_RUNS = 30
62
+
63
+ SOURCE_CHECK_RUNS = "check-runs"
64
+ SOURCE_ACTIONS = "actions"
65
+ ACTIONS_SOURCE_NOTE = (
66
+ "Check runs were read from the Actions API because this token cannot read "
67
+ "check-runs (a fine-grained token cannot be granted Checks). Checks "
68
+ "reported by apps other than GitHub Actions are not visible."
69
+ )
70
+
48
71
  # A required check: its context (check-run name or status context) and, when
49
72
  # the rule names one, the id of the GitHub App that must report it.
50
73
  RequiredCheck = tuple[str, int | None]
@@ -224,10 +247,11 @@ async def get_pull_request_checks(
224
247
  if not sha:
225
248
  raise ValidationError("Could not resolve pull request head SHA")
226
249
 
227
- check_runs = await client.get(f"/repos/{owner}/{repo}/commits/{sha}/check-runs")
250
+ truncated: list[str] = []
251
+ check_runs, source = await _head_check_runs(client, owner, repo, sha, truncated.append)
228
252
  combined_status = await client.get(f"/repos/{owner}/{repo}/commits/{sha}/status")
229
253
 
230
- buckets = _classify_checks(check_runs, combined_status)
254
+ buckets = _classify_checks({"check_runs": check_runs}, combined_status)
231
255
  failing = buckets["failing"]
232
256
  pending = buckets["pending"]
233
257
  skipped = buckets["skipped"]
@@ -236,12 +260,13 @@ async def get_pull_request_checks(
236
260
  mergeable = pr.get("mergeable")
237
261
  ready_to_merge = not failing and not pending and mergeable is not False
238
262
 
239
- return {
263
+ verdict: dict[str, Any] = {
240
264
  "pull_number": pull_number,
241
265
  "head_sha": sha,
242
266
  "mergeable": mergeable,
243
267
  "mergeable_state": pr.get("mergeable_state", ""),
244
268
  "ready_to_merge": ready_to_merge,
269
+ "checks_source": source,
245
270
  "summary": {
246
271
  "passed": len(passed),
247
272
  "failing": len(failing),
@@ -253,6 +278,13 @@ async def get_pull_request_checks(
253
278
  "skipped": skipped,
254
279
  "passed": passed,
255
280
  }
281
+ if source == SOURCE_ACTIONS:
282
+ verdict["note"] = ACTIONS_SOURCE_NOTE
283
+ if truncated:
284
+ # A cut list cannot clear the PR: an unread page may hold a failure.
285
+ verdict["ready_to_merge"] = False
286
+ verdict["truncated"] = "Not everything was read of: " + ", ".join(truncated)
287
+ return verdict
256
288
 
257
289
 
258
290
  async def update_pull_request(
@@ -357,19 +389,39 @@ async def create_pull_request(
357
389
  }
358
390
 
359
391
 
360
- async def _read_all(client: GitHubClient, path: str, key: str | None) -> list[dict[str, Any]]:
361
- """Read every page of a list for the merge gate, or refuse.
392
+ # Called with the path of a list that holds more than was read.
393
+ OnCut = Callable[[str], None]
394
+
395
+ API_ERRORS = (GitHubApiError, NotFoundError, PermissionDeniedError, RateLimitError)
396
+
397
+
398
+ def _refuse_cut(path: str) -> None:
399
+ """The merge gate does not judge a list it could not read in full."""
400
+ raise MergeRefusedError(
401
+ f"Refusing to merge: {path} lists more than the gate reads; "
402
+ "the checks cannot be judged in full"
403
+ )
404
+
405
+
406
+ async def _read_pages(
407
+ client: GitHubClient,
408
+ path: str,
409
+ key: str | None,
410
+ on_cut: OnCut,
411
+ params: dict[str, Any] | None = None,
412
+ max_pages: int | None = None,
413
+ ) -> list[dict[str, Any]]:
414
+ """Read up to ``max_pages`` (default MERGE_GATE_MAX_PAGES) pages of a
415
+ list, calling ``on_cut`` if there were more; API errors propagate.
362
416
 
363
417
  ``key`` names the list inside an object response (``check_runs``,
364
- ``statuses``), whose length is known from ``total_count``; None means
365
- the response is a bare list, paged by its Link header.
418
+ ``statuses``, ``jobs``), whose length is known from ``total_count``; None
419
+ means the response is a bare list, paged by its Link header.
366
420
  """
367
421
  collected: list[dict[str, Any]] = []
368
- for page in range(1, MERGE_GATE_MAX_PAGES + 1):
369
- try:
370
- result = await client.get(path, params={"per_page": MAX_PER_PAGE, "page": page})
371
- except (GitHubApiError, NotFoundError, PermissionDeniedError, RateLimitError) as e:
372
- raise MergeRefusedError(f"Refusing to merge: could not read {path}: {e}") from e
422
+ for page in range(1, (max_pages or MERGE_GATE_MAX_PAGES) + 1):
423
+ query = {**(params or {}), "per_page": MAX_PER_PAGE, "page": page}
424
+ result = await client.get(path, params=query)
373
425
  if key is None:
374
426
  items = result.get("items") or []
375
427
  more = "next_page" in result.get("pagination", {})
@@ -379,10 +431,86 @@ async def _read_all(client: GitHubClient, path: str, key: str | None) -> list[di
379
431
  collected.extend(item for item in items if isinstance(item, dict))
380
432
  if not more:
381
433
  return collected
382
- raise MergeRefusedError(
383
- f"Refusing to merge: {path} has more than {MERGE_GATE_MAX_PAGES} pages; "
384
- "the checks cannot be judged in full"
434
+ on_cut(path)
435
+ return collected
436
+
437
+
438
+ async def _read_all(client: GitHubClient, path: str, key: str | None) -> list[dict[str, Any]]:
439
+ """Read every page of a list for the merge gate, or refuse."""
440
+ try:
441
+ return await _read_pages(client, path, key, _refuse_cut)
442
+ except API_ERRORS as e:
443
+ raise MergeRefusedError(f"Refusing to merge: could not read {path}: {e}") from e
444
+
445
+
446
+ def _job_as_run(job: dict[str, Any]) -> dict[str, Any]:
447
+ """An Actions job in the shape of the check run it is."""
448
+ return {
449
+ "id": job.get("id"),
450
+ "name": job.get("name"),
451
+ "status": job.get("status"),
452
+ "conclusion": job.get("conclusion"),
453
+ "html_url": job.get("html_url"),
454
+ "app": {"id": ACTIONS_APP_ID},
455
+ }
456
+
457
+
458
+ async def _actions_check_runs(
459
+ client: GitHubClient, owner: str, repo: str, sha: str, on_cut: OnCut
460
+ ) -> list[dict[str, Any]]:
461
+ """The head commit's Actions jobs, as check runs.
462
+
463
+ Only the newest run of each workflow and event is read: an older run's
464
+ job is superseded by the newer run's job of the same name, and a job only
465
+ the older run had is then missing, which refuses rather than passes. Each
466
+ run lists the jobs of its latest attempt, up to MAX_RUN_JOBS, read
467
+ ACTIONS_JOB_READS_AT_ONCE runs at a time. Past ACTIONS_MAX_RUNS runs, only
468
+ the newest are read and ``on_cut`` is called.
469
+ """
470
+ runs_path = f"/repos/{owner}/{repo}/actions/runs"
471
+ workflow_runs = await _read_pages(
472
+ client, runs_path, "workflow_runs", on_cut, params={"head_sha": sha}
385
473
  )
474
+ newest: dict[tuple[Any, Any], int] = {}
475
+ for workflow_run in workflow_runs:
476
+ run_id = workflow_run.get("id")
477
+ key = (workflow_run.get("workflow_id"), workflow_run.get("event"))
478
+ if isinstance(run_id, int) and run_id > newest.get(key, 0):
479
+ newest[key] = run_id
480
+ run_ids = sorted(newest.values())
481
+ if len(run_ids) > ACTIONS_MAX_RUNS:
482
+ on_cut(runs_path)
483
+ run_ids = run_ids[-ACTIONS_MAX_RUNS:]
484
+ gate = asyncio.Semaphore(ACTIONS_JOB_READS_AT_ONCE)
485
+
486
+ async def run_jobs(run_id: int) -> list[dict[str, Any]]:
487
+ async with gate:
488
+ jobs_path = f"{runs_path}/{run_id}/jobs"
489
+ pages = MAX_RUN_JOBS // MAX_PER_PAGE
490
+ return await _read_pages(client, jobs_path, "jobs", on_cut, max_pages=pages)
491
+
492
+ per_run = await asyncio.gather(*(run_jobs(run_id) for run_id in run_ids))
493
+ return [_job_as_run(job) for jobs in per_run for job in jobs if job.get("head_sha") == sha]
494
+
495
+
496
+ async def _head_check_runs(
497
+ client: GitHubClient, owner: str, repo: str, sha: str, on_cut: OnCut
498
+ ) -> tuple[list[dict[str, Any]], str]:
499
+ """The head commit's check runs and where they were read from.
500
+
501
+ A token that may not read check-runs (any fine-grained token) gets 403
502
+ and the Actions jobs instead; any other error propagates. The client
503
+ maps a rate-limit 403 to RateLimitError and a 401 (a token GitHub
504
+ rejects outright) to PermissionDeniedError, so a GitHubApiError 403 is a
505
+ permission refusal.
506
+ """
507
+ path = f"/repos/{owner}/{repo}/commits/{sha}/check-runs"
508
+ try:
509
+ return await _read_pages(client, path, "check_runs", on_cut), SOURCE_CHECK_RUNS
510
+ except GitHubApiError as e:
511
+ if e.status_code != 403:
512
+ raise
513
+ return await _actions_check_runs(client, owner, repo, sha, on_cut), SOURCE_ACTIONS
386
514
 
387
515
 
388
516
  def _app_id(value: object) -> int | None:
@@ -463,16 +591,24 @@ def _judge_run(run: dict[str, Any]) -> str | None:
463
591
  return None
464
592
 
465
593
 
466
- def _index_runs(check_runs: list[dict[str, Any]]) -> dict[RequiredCheck, dict[str, Any]]:
467
- """The most recent (highest id) check run per (name, app id) and per
468
- (name, None), the latter for checks any app may report."""
594
+ def _index_runs(
595
+ check_runs: list[dict[str, Any]], any_app: bool = True
596
+ ) -> dict[RequiredCheck, dict[str, Any]]:
597
+ """The most recent (highest id) check run per (name, app id) and, when
598
+ ``any_app``, per (name, None) for checks any app may report.
599
+
600
+ Without ``any_app`` (the runs are only the Actions jobs, so another app's
601
+ run of the same name may be unseen) a check any app may report is judged
602
+ by its commit status alone, or is missing.
603
+ """
469
604
  index: dict[RequiredCheck, dict[str, Any]] = {}
470
605
  for run in sorted(check_runs, key=lambda r: r.get("id") or 0):
471
606
  name = run.get("name")
472
607
  if not isinstance(name, str):
473
608
  continue
474
609
  app_id = (run.get("app") or {}).get("id")
475
- index[(name, None)] = run
610
+ if any_app:
611
+ index[(name, None)] = run
476
612
  if isinstance(app_id, int):
477
613
  index[(name, app_id)] = run
478
614
  return index
@@ -514,11 +650,15 @@ async def _gate_problems(
514
650
  repo: str,
515
651
  head_sha: str,
516
652
  required: set[RequiredCheck],
517
- ) -> list[str]:
518
- """One line per required check that the head commit does not satisfy."""
519
- check_runs = await _read_all(
520
- client, f"/repos/{owner}/{repo}/commits/{head_sha}/check-runs", "check_runs"
521
- )
653
+ ) -> tuple[list[str], str]:
654
+ """One line per required check that the head commit does not satisfy,
655
+ and where the check runs were read from."""
656
+ try:
657
+ check_runs, source = await _head_check_runs(client, owner, repo, head_sha, _refuse_cut)
658
+ except API_ERRORS as e:
659
+ raise MergeRefusedError(
660
+ f"Refusing to merge: could not read the check runs of {head_sha}: {e}"
661
+ ) from e
522
662
  raw_statuses = await _read_all(
523
663
  client, f"/repos/{owner}/{repo}/commits/{head_sha}/status", "statuses"
524
664
  )
@@ -529,13 +669,13 @@ async def _gate_problems(
529
669
  if isinstance(status.get("context"), str):
530
670
  statuses[status["context"]] = status
531
671
 
532
- runs = _index_runs(check_runs)
672
+ runs = _index_runs(check_runs, any_app=source == SOURCE_CHECK_RUNS)
533
673
  problems = []
534
674
  for check in sorted(required, key=lambda c: (c[0], c[1] or 0)):
535
675
  reason = _judge_check(check, runs, statuses)
536
676
  if reason:
537
677
  problems.append(f"{check[0]}: {reason}")
538
- return problems
678
+ return problems, source
539
679
 
540
680
 
541
681
  async def _head_and_base(
@@ -617,11 +757,17 @@ async def merge_pull_request(
617
757
  f"Refusing to merge: branch {base!r} requires no status checks. "
618
758
  "Pass allow_no_required_checks=True to merge without any."
619
759
  )
760
+ source: str | None = None
620
761
  if required:
621
- problems = await _gate_problems(client, owner, repo, head_sha, required)
762
+ problems, source = await _gate_problems(client, owner, repo, head_sha, required)
622
763
  if problems:
764
+ via = (
765
+ " (check runs read from the Actions API; this token cannot read check-runs)"
766
+ if source == SOURCE_ACTIONS
767
+ else ""
768
+ )
623
769
  raise MergeRefusedError(
624
- f"Refusing to merge: required checks are not green on {head_sha}: "
770
+ f"Refusing to merge: required checks are not green on {head_sha}{via}: "
625
771
  + "; ".join(problems)
626
772
  )
627
773
 
@@ -643,6 +789,7 @@ async def merge_pull_request(
643
789
  "message": result.get("message"),
644
790
  "head_sha": head_sha,
645
791
  "required_checks": sorted({context for context, _ in required}),
792
+ "checks_source": source,
646
793
  }
647
794
 
648
795
 
@@ -38,6 +38,27 @@ def run(
38
38
  }
39
39
 
40
40
 
41
+ def job(
42
+ name: str,
43
+ conclusion: str | None = "success",
44
+ status: str = "completed",
45
+ job_id: int = 1,
46
+ head_sha: str = HEAD,
47
+ ) -> dict[str, Any]:
48
+ """An Actions job as GitHub returns it; its id is its check run's id."""
49
+ return {
50
+ "id": job_id,
51
+ "name": name,
52
+ "status": status,
53
+ "conclusion": conclusion,
54
+ "head_sha": head_sha,
55
+ "html_url": f"https://github.com/o/r/actions/runs/1/job/{job_id}",
56
+ }
57
+
58
+
59
+ DENIED = (403, "Resource not accessible by personal access token")
60
+
61
+
41
62
  def ruleset(*contexts: str | tuple[str, int]) -> dict[str, Any]:
42
63
  """A required_status_checks rule; a tuple names the integration too."""
43
64
  checks = []
@@ -71,13 +92,19 @@ class MockGitHub:
71
92
  statuses: list[dict[str, Any]] | None = None,
72
93
  page_size: int = 100,
73
94
  base: str = "main",
74
- fail: dict[str, tuple[int, str]] | None = None,
95
+ fail: dict[str, tuple[Any, ...]] | None = None,
75
96
  moved: dict[str, str] | None = None,
97
+ jobs: dict[int, list[dict[str, Any]]] | None = None,
98
+ workflow_of: dict[int, int] | None = None,
76
99
  ) -> None:
77
100
  self.rules = rules or []
78
101
  self.classic = classic
79
102
  self.check_runs = check_runs or []
80
103
  self.statuses = statuses or []
104
+ # Actions jobs per workflow run id, read when check-runs is denied.
105
+ self.jobs = jobs or {}
106
+ # The workflow of each run id; by default each run is its own workflow.
107
+ self.workflow_of = workflow_of or {}
81
108
  self.page_size = page_size
82
109
  self.base = base
83
110
  self.fail = fail or {}
@@ -93,6 +120,8 @@ class MockGitHub:
93
120
  ("classic", "GET", r"/repos/o/r/branches/.+", self._classic),
94
121
  ("check_runs", "GET", rf"/repos/o/r/commits/{HEAD}/check-runs", self._check_runs),
95
122
  ("statuses", "GET", rf"/repos/o/r/commits/{HEAD}/status", self._statuses),
123
+ ("workflow_runs", "GET", r"/repos/o/r/actions/runs", self._workflow_runs),
124
+ ("jobs", "GET", r"/repos/o/r/actions/runs/\d+/jobs", self._jobs),
96
125
  ]
97
126
 
98
127
  def _page(self, request: httpx.Request, items: list[dict[str, Any]]) -> tuple[list[Any], bool]:
@@ -107,7 +136,8 @@ class MockGitHub:
107
136
  failure = self.fail.get(name)
108
137
  if failure is None:
109
138
  return handler(request)
110
- return httpx.Response(failure[0], json={"message": failure[1]})
139
+ headers = failure[2] if len(failure) > 2 else {}
140
+ return httpx.Response(failure[0], json={"message": failure[1]}, headers=headers)
111
141
  return httpx.Response(404, json={"message": f"unexpected {request.url.path}"})
112
142
 
113
143
  def _merge(self, _request: httpx.Request) -> httpx.Response:
@@ -149,6 +179,20 @@ class MockGitHub:
149
179
  body = {"state": "x", "total_count": len(self.statuses), "statuses": items}
150
180
  return httpx.Response(200, json=body)
151
181
 
182
+ def _workflow_runs(self, request: httpx.Request) -> httpx.Response:
183
+ assert request.url.params.get("head_sha") == HEAD
184
+ runs = [
185
+ {"id": run_id, "head_sha": HEAD, "workflow_id": self.workflow_of.get(run_id, run_id)}
186
+ for run_id in self.jobs
187
+ ]
188
+ items, _ = self._page(request, runs)
189
+ return httpx.Response(200, json={"total_count": len(runs), "workflow_runs": items})
190
+
191
+ def _jobs(self, request: httpx.Request) -> httpx.Response:
192
+ jobs = self.jobs[int(request.url.path.split("/")[-2])]
193
+ items, _ = self._page(request, jobs)
194
+ return httpx.Response(200, json={"total_count": len(jobs), "jobs": items})
195
+
152
196
  @property
153
197
  def merges(self) -> list[httpx.Request]:
154
198
  return [r for r in self.requests if r.method == "PUT"]
@@ -194,6 +238,7 @@ class TestMergeGate:
194
238
  "message": "Merged",
195
239
  "head_sha": HEAD,
196
240
  "required_checks": ["Gatehouse triage", "lint", "test"],
241
+ "checks_source": "check-runs",
197
242
  }
198
243
  (put,) = github.merges
199
244
  assert put.url.path == MERGE_PATH
@@ -493,7 +538,7 @@ class TestMergeGate:
493
538
  page_size=2,
494
539
  )
495
540
 
496
- assert "more than 2 pages" in await refused(github)
541
+ assert "lists more than the gate reads" in await refused(github)
497
542
 
498
543
  async def test_pr_without_head_refused(self) -> None:
499
544
  from mcp_github_crunchtools.errors import MergeRefusedError
@@ -542,7 +587,7 @@ class TestMergeGate:
542
587
 
543
588
  @pytest.mark.parametrize(
544
589
  ("route", "code"),
545
- [("rules", 403), ("rules", 500), ("check_runs", 403), ("statuses", 500), ("pull", 404)],
590
+ [("rules", 403), ("rules", 500), ("check_runs", 500), ("statuses", 500), ("pull", 404)],
546
591
  )
547
592
  async def test_unreadable_gate_data_never_merges(self, route: str, code: int) -> None:
548
593
  from mcp_github_crunchtools.errors import UserError
@@ -560,6 +605,245 @@ class TestMergeGate:
560
605
  assert github.merges == []
561
606
 
562
607
 
608
+ class TestFineGrainedToken:
609
+ """A fine-grained token cannot read check-runs; the Actions jobs stand in."""
610
+
611
+ async def test_green_jobs_merge(self) -> None:
612
+ github = MockGitHub(
613
+ rules=[ruleset(("test", ACTIONS_APP), ("lint", ACTIONS_APP))],
614
+ fail={"check_runs": DENIED},
615
+ jobs={7: [job("test", job_id=11)], 8: [job("lint", job_id=12)]},
616
+ )
617
+
618
+ result = await merge(github)
619
+
620
+ assert result["merged"] is True
621
+ assert result["checks_source"] == "actions"
622
+ assert github.merges[0].url.path == MERGE_PATH
623
+
624
+ @pytest.mark.parametrize(
625
+ ("conclusion", "status", "reason"),
626
+ [
627
+ ("failure", "completed", "failed (check run concluded failure)"),
628
+ (None, "in_progress", "pending (check run in_progress)"),
629
+ ],
630
+ )
631
+ async def test_red_or_running_job_refused(
632
+ self, conclusion: str | None, status: str, reason: str
633
+ ) -> None:
634
+ github = MockGitHub(
635
+ rules=[ruleset(("test", ACTIONS_APP))],
636
+ fail={"check_runs": DENIED},
637
+ jobs={7: [job("test", conclusion=conclusion, status=status)]},
638
+ )
639
+
640
+ message = await refused(github)
641
+
642
+ assert f"test: {reason}" in message
643
+ assert "read from the Actions API" in message
644
+
645
+ async def test_latest_rerun_decides(self) -> None:
646
+ github = MockGitHub(
647
+ rules=[ruleset(("test", ACTIONS_APP))],
648
+ fail={"check_runs": DENIED},
649
+ jobs={7: [job("test", "failure", job_id=11)], 9: [job("test", job_id=20)]},
650
+ )
651
+
652
+ assert (await merge(github))["merged"] is True
653
+
654
+ async def test_older_run_of_same_workflow_not_read(self) -> None:
655
+ github = MockGitHub(
656
+ rules=[ruleset(("test", ACTIONS_APP))],
657
+ fail={"check_runs": DENIED},
658
+ jobs={7: [job("test", job_id=11)], 9: [job("lint", job_id=20)]},
659
+ workflow_of={7: 1, 9: 1},
660
+ )
661
+
662
+ assert "test: missing" in await refused(github)
663
+ assert "/repos/o/r/actions/runs/7/jobs" not in github.paths()
664
+
665
+ async def test_too_many_workflows_refused(self, monkeypatch: pytest.MonkeyPatch) -> None:
666
+ from mcp_github_crunchtools.tools import pull_requests as pr_mod
667
+
668
+ monkeypatch.setattr(pr_mod, "ACTIONS_MAX_RUNS", 1)
669
+ github = MockGitHub(
670
+ rules=[ruleset(("test", ACTIONS_APP))],
671
+ fail={"check_runs": DENIED},
672
+ jobs={7: [job("test")], 8: [job("lint")]},
673
+ )
674
+
675
+ assert "/actions/runs lists more than the gate reads" in await refused(github)
676
+ assert not any(path.endswith("/jobs") for path in github.paths())
677
+
678
+ async def test_any_app_check_not_satisfied_by_a_job(self) -> None:
679
+ # Another app's run of the same name may be failing, unseen.
680
+ github = MockGitHub(
681
+ rules=[ruleset("test")], fail={"check_runs": DENIED}, jobs={7: [job("test")]}
682
+ )
683
+
684
+ assert "test: missing" in await refused(github)
685
+
686
+ async def test_any_app_check_still_met_by_status(self) -> None:
687
+ github = MockGitHub(
688
+ rules=[ruleset("ci/legacy")],
689
+ fail={"check_runs": DENIED},
690
+ statuses=[{"id": 1, "context": "ci/legacy", "state": "success"}],
691
+ )
692
+
693
+ assert (await merge(github))["merged"] is True
694
+
695
+ async def test_other_app_check_refused(self) -> None:
696
+ github = MockGitHub(
697
+ rules=[ruleset(("test", 999))], fail={"check_runs": DENIED}, jobs={7: [job("test")]}
698
+ )
699
+
700
+ assert "test: missing (no check run from app 999" in await refused(github)
701
+
702
+ async def test_job_on_another_commit_ignored(self) -> None:
703
+ github = MockGitHub(
704
+ rules=[ruleset(("test", ACTIONS_APP))],
705
+ fail={"check_runs": DENIED},
706
+ jobs={7: [job("test", head_sha=OTHER)]},
707
+ )
708
+
709
+ assert "test: missing" in await refused(github)
710
+
711
+ @pytest.mark.parametrize("failure", [(500, "boom"), (404, "Not Found")])
712
+ async def test_other_check_runs_errors_do_not_fall_back(self, failure: tuple[int, str]) -> None:
713
+ github = MockGitHub(
714
+ rules=[ruleset(("test", ACTIONS_APP))],
715
+ fail={"check_runs": failure},
716
+ jobs={7: [job("test")]},
717
+ )
718
+
719
+ assert "could not read the check runs of" in await refused(github)
720
+ assert not any("/actions/" in path for path in github.paths())
721
+
722
+ async def test_rejected_token_does_not_fall_back(self) -> None:
723
+ github = MockGitHub(
724
+ rules=[ruleset(("test", ACTIONS_APP))],
725
+ fail={"check_runs": (401, "Bad credentials")},
726
+ jobs={7: [job("test")]},
727
+ )
728
+
729
+ assert "could not read the check runs of" in await refused(github)
730
+ assert not any("/actions/" in path for path in github.paths())
731
+
732
+ async def test_rate_limited_check_runs_do_not_fall_back(self) -> None:
733
+ limited = (403, "API rate limit exceeded", {"x-ratelimit-remaining": "0"})
734
+ github = MockGitHub(
735
+ rules=[ruleset(("test", ACTIONS_APP))],
736
+ fail={"check_runs": limited},
737
+ jobs={7: [job("test")]},
738
+ )
739
+
740
+ assert "could not read the check runs of" in await refused(github)
741
+ assert not any("/actions/" in path for path in github.paths())
742
+
743
+ async def test_too_many_jobs_in_a_run_refused(self) -> None:
744
+ from mcp_github_crunchtools.tools.actions import MAX_RUN_JOBS
745
+
746
+ github = MockGitHub(
747
+ rules=[ruleset(("test", ACTIONS_APP))],
748
+ fail={"check_runs": DENIED},
749
+ jobs={7: [job("test", job_id=i) for i in range(1, MAX_RUN_JOBS + 2)]},
750
+ )
751
+
752
+ assert "/actions/runs/7/jobs lists more than the gate reads" in await refused(github)
753
+
754
+ async def test_too_many_workflow_run_pages_refused(
755
+ self, monkeypatch: pytest.MonkeyPatch
756
+ ) -> None:
757
+ from mcp_github_crunchtools.tools import pull_requests as pr_mod
758
+
759
+ monkeypatch.setattr(pr_mod, "MERGE_GATE_MAX_PAGES", 1)
760
+ github = MockGitHub(
761
+ rules=[ruleset(("test", ACTIONS_APP))],
762
+ fail={"check_runs": DENIED},
763
+ jobs={7: [job("test")], 8: [job("test", job_id=2)]},
764
+ page_size=1,
765
+ )
766
+
767
+ assert "/actions/runs lists more than the gate reads" in await refused(github)
768
+
769
+ async def test_unreadable_jobs_refuse(self) -> None:
770
+ github = MockGitHub(
771
+ rules=[ruleset(("test", ACTIONS_APP))],
772
+ fail={"check_runs": DENIED, "jobs": DENIED},
773
+ jobs={7: [job("test")]},
774
+ )
775
+
776
+ assert "could not read the check runs of" in await refused(github)
777
+
778
+ async def test_checks_tool_reads_jobs(self) -> None:
779
+ from mcp_github_crunchtools.tools import get_pull_request_checks
780
+
781
+ github = MockGitHub(
782
+ fail={"check_runs": DENIED},
783
+ jobs={7: [job("test", job_id=11), job("lint", "skipped", job_id=12)]},
784
+ )
785
+ with _patch_transport(github):
786
+ verdict = await get_pull_request_checks(owner="o", repo="r", pull_number=12)
787
+
788
+ assert verdict["checks_source"] == "actions"
789
+ assert "not visible" in verdict["note"]
790
+ assert verdict["summary"] == {"passed": 1, "failing": 0, "pending": 0, "skipped": 1}
791
+ assert verdict["ready_to_merge"] is True
792
+
793
+ async def test_checks_tool_reads_later_pages(self) -> None:
794
+ from mcp_github_crunchtools.tools import get_pull_request_checks
795
+
796
+ github = MockGitHub(
797
+ check_runs=[run("a", run_id=1), run("b", conclusion="failure", run_id=2)], page_size=1
798
+ )
799
+ with _patch_transport(github):
800
+ verdict = await get_pull_request_checks(owner="o", repo="r", pull_number=12)
801
+
802
+ pages = [r.url.params.get("page") for r in github.requests if "check-runs" in r.url.path]
803
+ assert pages == ["1", "2"]
804
+ assert [f["name"] for f in verdict["failing"]] == ["b"]
805
+ assert verdict["ready_to_merge"] is False
806
+ assert "truncated" not in verdict
807
+
808
+ async def test_checks_tool_flags_truncation(self, monkeypatch: pytest.MonkeyPatch) -> None:
809
+ from mcp_github_crunchtools.tools import get_pull_request_checks
810
+ from mcp_github_crunchtools.tools import pull_requests as pr_mod
811
+
812
+ monkeypatch.setattr(pr_mod, "MERGE_GATE_MAX_PAGES", 1)
813
+ github = MockGitHub(check_runs=[run("a", run_id=1), run("b", run_id=2)], page_size=1)
814
+ with _patch_transport(github):
815
+ verdict = await get_pull_request_checks(owner="o", repo="r", pull_number=12)
816
+
817
+ assert verdict["ready_to_merge"] is False
818
+ assert "check-runs" in verdict["truncated"]
819
+
820
+ async def test_checks_tool_reads_newest_runs_past_the_cap(
821
+ self, monkeypatch: pytest.MonkeyPatch
822
+ ) -> None:
823
+ from mcp_github_crunchtools.tools import get_pull_request_checks
824
+ from mcp_github_crunchtools.tools import pull_requests as pr_mod
825
+
826
+ monkeypatch.setattr(pr_mod, "ACTIONS_MAX_RUNS", 1)
827
+ github = MockGitHub(
828
+ fail={"check_runs": DENIED},
829
+ jobs={7: [job("old", job_id=11)], 8: [job("new", job_id=12)]},
830
+ )
831
+ with _patch_transport(github):
832
+ verdict = await get_pull_request_checks(owner="o", repo="r", pull_number=12)
833
+
834
+ assert "/repos/o/r/actions/runs/7/jobs" not in github.paths()
835
+ assert [p["name"] for p in verdict["passed"]] == ["new"]
836
+ assert "/repos/o/r/actions/runs" in verdict["truncated"]
837
+ assert verdict["ready_to_merge"] is False
838
+
839
+ async def test_no_required_checks_reports_no_source(self) -> None:
840
+ github = MockGitHub()
841
+
842
+ result = await merge(github, allow_no_required_checks=True)
843
+
844
+ assert result["checks_source"] is None
845
+
846
+
563
847
  class TestMergeTool:
564
848
  """The registered MCP tool exposes and forwards the gate's argument."""
565
849
 
@@ -922,7 +922,7 @@ wheels = [
922
922
 
923
923
  [[package]]
924
924
  name = "mcp-github-crunchtools"
925
- version = "1.4.0"
925
+ version = "1.5.0"
926
926
  source = { editable = "." }
927
927
  dependencies = [
928
928
  { name = "fastmcp" },