mcp-github-crunchtools 1.4.0__tar.gz → 1.6.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.
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/CHANGELOG.md +25 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/Containerfile +3 -3
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/PKG-INFO +20 -10
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/README.md +19 -9
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/pyproject.toml +1 -1
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/server.json +2 -2
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/__init__.py +1 -1
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/errors.py +1 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/server.py +7 -3
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/tools/pull_requests.py +175 -28
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/tests/test_merge_gate.py +288 -4
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/uv.lock +1 -1
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.gatehouse-ignore +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/dependabot.yml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/ashigaru.yml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/ci.yml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/constitution.yml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/container.yml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/dependabot-automerge.yml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/gatehouse-retriage.yml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/gatehouse.yml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/publish.yml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/security.yml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.gitignore +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.pre-commit-config.yaml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.specify/memory/constitution.md +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.specify/specs/000-baseline/spec.md +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.specify/specs/001-pr-merge-logs-release/spec.md +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.specify/specs/002-request-token-merge-gate/spec.md +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.specify/templates/plan-template.md +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.specify/templates/spec-template.md +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/CLAUDE.md +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/LICENSE +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/SECURITY.md +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/gourmand-exceptions.toml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/gourmand.toml +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/__main__.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/client.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/config.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/models.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/tools/__init__.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/tools/actions.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/tools/files.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/tools/issues.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/tools/releases.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/tools/search.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/tests/__init__.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/tests/conftest.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/tests/test_request_token.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/tests/test_tools.py +0 -0
- {mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/tests/test_validation.py +0 -0
|
@@ -6,6 +6,31 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.6.0] - 2026-10-20
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Container base images pinned to `quay.io/hummingbird/python:3.14*` instead of
|
|
14
|
+
`latest`, which moved to Python 3.15.0 on 2026-10-20 (crunchtools/constitution#66).
|
|
15
|
+
|
|
16
|
+
## [1.5.0] - 2026-10-20
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `merge_pull_request_tool` and `get_pull_request_checks_tool` work with a
|
|
21
|
+
fine-grained token. GitHub cannot grant one the Checks permission, so
|
|
22
|
+
check-runs answers 403; the tools then read the head commit's Actions jobs
|
|
23
|
+
(Actions: read), each of which is a check run with the same id and name.
|
|
24
|
+
Only Actions-reported checks are visible that way, so the merge gate
|
|
25
|
+
refuses a required check that names another app, or that accepts any app
|
|
26
|
+
and has no commit status. Both results carry `checks_source`.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- `get_pull_request_checks_tool` reads every page of check runs, not just the
|
|
31
|
+
first, up to the merge gate's page limit; past it the verdict says
|
|
32
|
+
`truncated` and is not `ready_to_merge`.
|
|
33
|
+
|
|
9
34
|
## [1.4.0] - 2026-10-17
|
|
10
35
|
|
|
11
36
|
### Added
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
# -- podman run -i --rm -e GITHUB_TOKEN quay.io/crunchtools/mcp-github
|
|
14
14
|
|
|
15
15
|
# Stage 1: Builder (has a shell, dnf and build tools)
|
|
16
|
-
FROM quay.io/hummingbird/python:
|
|
16
|
+
FROM quay.io/hummingbird/python:3.14-builder AS builder
|
|
17
17
|
USER 0
|
|
18
18
|
WORKDIR /app
|
|
19
19
|
RUN python3 -m venv /app/venv
|
|
@@ -23,11 +23,11 @@ COPY src/ ./src/
|
|
|
23
23
|
RUN pip install --no-cache-dir .
|
|
24
24
|
|
|
25
25
|
# Stage 2: Runtime (distroless -- no shell, no package manager)
|
|
26
|
-
FROM quay.io/hummingbird/python:
|
|
26
|
+
FROM quay.io/hummingbird/python:3.14
|
|
27
27
|
|
|
28
28
|
# Labels for container metadata
|
|
29
29
|
LABEL name="mcp-github-crunchtools" \
|
|
30
|
-
version="1.
|
|
30
|
+
version="1.6.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.
|
|
3
|
+
Version: 1.6.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
|
|
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,
|
|
231
|
-
for fine-grained tokens
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
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
|
|
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,
|
|
205
|
-
for fine-grained tokens
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
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
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/crunchtools/mcp-github",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.6.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "mcp-github-crunchtools",
|
|
15
|
-
"version": "1.
|
|
15
|
+
"version": "1.6.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/server.py
RENAMED
|
@@ -46,7 +46,7 @@ READ_ONLY = {"readOnlyHint": True}
|
|
|
46
46
|
|
|
47
47
|
mcp = FastMCP(
|
|
48
48
|
name="mcp-github",
|
|
49
|
-
version="1.
|
|
49
|
+
version="1.6.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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(
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
|
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",
|
|
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
|
|
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/ISSUE_TEMPLATE/bug_report.yml
RENAMED
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/ISSUE_TEMPLATE/config.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/ashigaru.yml
RENAMED
|
File without changes
|
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/constitution.yml
RENAMED
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/container.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/gatehouse.yml
RENAMED
|
File without changes
|
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.github/workflows/security.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.specify/memory/constitution.md
RENAMED
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.specify/specs/000-baseline/spec.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.specify/templates/plan-template.md
RENAMED
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/.specify/templates/spec-template.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/__main__.py
RENAMED
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/client.py
RENAMED
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/config.py
RENAMED
|
File without changes
|
{mcp_github_crunchtools-1.4.0 → mcp_github_crunchtools-1.6.0}/src/mcp_github_crunchtools/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|