From aa32e8c0132751332a011d17c2628032954f858c Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Tue, 6 Oct 2026 15:29:41 +0200 Subject: [PATCH 1/5] feat(github-mcp): add comment_edit to edit any existing comment Agents could create comments but not correct them: the `api` write tool sends no request body, and the `gh` hook blocks `gh pr comment --edit-last`. `comment_edit` takes the comment's URL and a new body. The URL anchor decides the call, because GitHub keeps issue comments, inline review comments and review summaries in separate id sequences, so a bare numeric id cannot identify the kind. `#issuecomment-ID` goes to `PATCH issues/comments/ID`, `#pullrequestreview-ID` to `PUT pulls/N/reviews/ID`, and `#discussion_rID` or `/files#rID` / `/changes#rID` to `PATCH pulls/comments/ID`. REST answers 404 for a comment in the caller's unsubmitted review, so for inline anchors the tool first queries the caller's pending review over GraphQL and, on a match, edits it with `updatePullRequestReviewComment`. Every failure, including a lookup error, a missing URL in GitHub's response, or more than 100 pending comments without a match, returns an error. No route falls back to another. The tool declares no `fallback` or `suppress_errors`, since `fallback` would report a failed edit as a success. `check-api-tools.sh` redirects the three edit endpoints on the `api` tool to `comment_edit`, and the `gh pr comment` / `gh issue comment` block messages name it. Co-Authored-By: Claude --- .github/ISSUE_TEMPLATE/mcp_issue.yml | 2 +- plugin-tests/github-mcp/check_api_tools.bats | 56 ++ plugin-tests/github-mcp/gh_tools_write.bats | 6 + .../github-mcp/session_start_write.bats | 8 + plugin-tests/github-mcp/tool_schemas.bats | 9 + .../github-mcp/write_tools_review.bats | 493 ++++++++++++++++++ plugins/github-mcp/AGENTS.md | 6 +- plugins/github-mcp/CHANGELOG.md | 21 + plugins/github-mcp/README.md | 12 +- plugins/github-mcp/REFERENCE.md | 29 +- plugins/github-mcp/SETUP.md | 3 +- .../hooks/prompts/write-operations-enabled.md | 2 +- .../hooks/scripts/check-api-tools.sh | 10 + .../hooks/scripts/check-gh-tools.sh | 4 +- .../mcp-server-gh/lib/review_write.sh | 160 +++++- .../github-mcp/mcp-server-gh/tools-write.json | 19 + .../references/plugin-setup.md | 3 +- 17 files changed, 826 insertions(+), 17 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/mcp_issue.yml b/.github/ISSUE_TEMPLATE/mcp_issue.yml index 32a1f2c..15b809d 100644 --- a/.github/ISSUE_TEMPLATE/mcp_issue.yml +++ b/.github/ISSUE_TEMPLATE/mcp_issue.yml @@ -79,7 +79,7 @@ body: - gh-tooling / label and project tools (label_list, project_list, project_view) - gh-tooling / api_read - gh-tooling-write / PR write tools (pr_create, pr_edit, pr_ready, pr_merge, pr_close, pr_reopen) - - gh-tooling-write / review tools (pr_review_submit, pr_comment, pr_review_reply) + - gh-tooling-write / review tools (pr_review_submit, pr_comment, pr_review_reply, comment_edit) - gh-tooling-write / issue write tools (issue_create, issue_edit, issue_close, issue_reopen, issue_comment) - gh-tooling-write / labels, assignees, sub-issues - gh-tooling-write / project writes (project_item_add, project_status_set) diff --git a/plugin-tests/github-mcp/check_api_tools.bats b/plugin-tests/github-mcp/check_api_tools.bats index 792c399..78b9dee 100644 --- a/plugin-tests/github-mcp/check_api_tools.bats +++ b/plugin-tests/github-mcp/check_api_tools.bats @@ -256,6 +256,62 @@ setup_write_blocking() { assert_output --partial "pr_review_reply" } +@test "write api: blocks PATCH issues/comments/ID → suggests comment_edit" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/issues/comments/456" "PATCH" + assert_failure 2 + assert_output --partial "comment_edit" + refute_output --partial "issue_edit" +} + +@test "write api: blocks PATCH pulls/comments/ID → suggests comment_edit" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/pulls/comments/456" "PATCH" + assert_failure 2 + assert_output --partial "comment_edit" +} + +@test "write api: blocks PUT pulls/N/reviews/ID → suggests comment_edit" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/pulls/123/reviews/456" "PUT" + assert_failure 2 + assert_output --partial "comment_edit" + refute_output --partial "pr_review_submit" +} + +@test "write api: a comment path only in the query string of another PATCH endpoint is not redirected to comment_edit" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/labels/bug?next=repos/shopware/shopware/issues/comments/456" "PATCH" + assert_success +} + +@test "write api: a review path only in the query string of another PUT endpoint is not redirected to comment_edit" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/pulls/123/requested_reviewers?next=repos/shopware/shopware/pulls/123/reviews/456" "PUT" + assert_success +} + +@test "write api: POST pulls/N/reviews still suggests pr_review_submit, not comment_edit" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/pulls/123/reviews" "POST" + assert_failure 2 + assert_output --partial "pr_review_submit" + refute_output --partial "comment_edit" +} + +@test "write api: DELETE issues/comments/ID is not redirected to comment_edit" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/issues/comments/456" "DELETE" + assert_success +} + +@test "Codex write api: PATCH issues/comments/ID suggests comment_edit" { + setup_codex_config "gh-tooling" '{"block_api_tool_write": true}' + run_api_hook "$CODEX_WRITE_TOOL" "repos/shopware/shopware/issues/comments/456" "PATCH" + assert_failure 2 + assert_output --partial "comment_edit" +} + @test "write api: allows unknown write endpoint" { setup_write_blocking run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/releases" "POST" diff --git a/plugin-tests/github-mcp/gh_tools_write.bats b/plugin-tests/github-mcp/gh_tools_write.bats index 69fd82b..6e12dd1 100644 --- a/plugin-tests/github-mcp/gh_tools_write.bats +++ b/plugin-tests/github-mcp/gh_tools_write.bats @@ -26,6 +26,9 @@ bats_test_function --description "blocks gh pr review → suggests pr_review_sub bats_test_function --description "blocks gh pr comment → suggests pr_comment" \ -- gh_hook_blocks "gh pr comment 100 --body 'lgtm'" "pr_comment" +bats_test_function --description "blocks gh pr comment --edit-last → also suggests comment_edit" \ + -- gh_hook_blocks "gh pr comment 100 --edit-last --body 'lgtm'" "comment_edit" + # Issue write commands bats_test_function --description "blocks gh issue create → suggests issue_create" \ -- gh_hook_blocks "gh issue create --title 'bug'" "issue_create" @@ -38,6 +41,9 @@ bats_test_function --description "blocks gh issue reopen → suggests issue_reop bats_test_function --description "blocks gh issue comment → suggests issue_comment" \ -- gh_hook_blocks "gh issue comment 50 --body 'fixed'" "issue_comment" +bats_test_function --description "blocks gh issue comment --edit-last → also suggests comment_edit" \ + -- gh_hook_blocks "gh issue comment 50 --edit-last --body 'fixed'" "comment_edit" + # Label commands bats_test_function --description "blocks gh label list → suggests label_list" \ -- gh_hook_blocks "gh label list --repo shopware/shopware" "label_list" diff --git a/plugin-tests/github-mcp/session_start_write.bats b/plugin-tests/github-mcp/session_start_write.bats index 109d958..a5334c6 100644 --- a/plugin-tests/github-mcp/session_start_write.bats +++ b/plugin-tests/github-mcp/session_start_write.bats @@ -23,6 +23,14 @@ extract_context() { [[ "$context" == *"pr_create"* ]] } +@test "write section lists comment_edit" { + run_session_with_config '{"enforce_mcp_tools": true, "enable_write_server": true}' + assert_success + local context + context=$(extract_context) + [[ "$context" == *"comment_edit"* ]] +} + @test "shows write disabled message when enable_write_server is false" { run_session_with_config '{"enforce_mcp_tools": true, "enable_write_server": false}' assert_success diff --git a/plugin-tests/github-mcp/tool_schemas.bats b/plugin-tests/github-mcp/tool_schemas.bats index 160d8b0..0ed76c2 100644 --- a/plugin-tests/github-mcp/tool_schemas.bats +++ b/plugin-tests/github-mcp/tool_schemas.bats @@ -159,6 +159,15 @@ load_validator_for() { assert_output --partial "Unknown parameter(s): body_file" } +@test "comment_edit refuses fallback, which would report a failed edit as a success" { + load_validator_for "$WRITE_TOOLS" + + run validate_tool_arguments "comment_edit" '{"url": "o/r/issues/1#issuecomment-2", "body": "x", "fallback": "ok"}' + + assert_failure + assert_output --partial "Unknown parameter(s): fallback" +} + @test "a paging limit sent as a string is refused, naming the parameter" { # The counterpart to the identifier union: limit and max_lines are # integer-only on purpose, so the string form must fail rather than reach diff --git a/plugin-tests/github-mcp/write_tools_review.bats b/plugin-tests/github-mcp/write_tools_review.bats index 465a892..424f386 100644 --- a/plugin-tests/github-mcp/write_tools_review.bats +++ b/plugin-tests/github-mcp/write_tools_review.bats @@ -20,6 +20,11 @@ setup() { # --input calls, and dispatches a canned head.sha for the commit_id fetch. gh() { printf '%s\n' "$*" >> "${GH_ARGS_FILE}" + # One file per call, one NUL-terminated record per argument, so a test + # can tell one argument from several that join to the same text. + local call_n + call_n=$(find "${BATS_TEST_TMPDIR}" -maxdepth 1 -name 'gh_argv.*' | wc -l | tr -d ' ') + printf '%s\0' "$@" > "${BATS_TEST_TMPDIR}/gh_argv.$((call_n + 1))" if [[ "$*" == *"--input"* ]]; then cat > "${GH_STDIN_FILE}" fi @@ -27,9 +32,33 @@ setup() { printf '%s\n' "${GH_STUB_HEAD_SHA:-0123456789abcdef0123456789abcdef01234567}" return 0 fi + # comment_edit's GraphQL calls, told apart by their query text. + if [[ "$*" == *"reviews(states: [PENDING]"* ]]; then + printf '%s\n' "${GH_STUB_LOOKUP_OUTPUT}" + return "${GH_STUB_LOOKUP_EXIT}" + fi + if [[ "$*" == *"updatePullRequestReviewComment"* ]]; then + printf '%s\n' "${GH_STUB_MUTATION_OUTPUT}" + return "${GH_STUB_MUTATION_EXIT}" + fi + if [[ -n "${GH_STUB_APPLY_JQ:-}" ]]; then + # Behave like real gh: run the --jq filter over the canned response. + local arg filter="" prev="" + for arg in "$@"; do + [[ "${prev}" == "--jq" ]] && filter="${arg}" + prev="${arg}" + done + printf '%s' "${GH_STUB_OUTPUT}" | jq -r "${filter}" + return $? + fi gh_stub_respond } reset_gh_stub + # Default: the caller has no pending review. + GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[]}}}}}' + GH_STUB_LOOKUP_EXIT=0 + GH_STUB_MUTATION_OUTPUT="" + GH_STUB_MUTATION_EXIT=0 GH_STUB_HEAD_SHA="0123456789abcdef0123456789abcdef01234567" } @@ -249,3 +278,467 @@ _assert_rejects_incomplete_comment() { assert_gh_args_contain "-X POST" assert_gh_args_contain "-f body=Addressed, thanks!" } + +# ============================================================================ +# comment_edit +# ============================================================================ + +# gh must not have run: the rejection happens before any API call. +assert_gh_not_called() { + [[ ! -s "${GH_ARGS_FILE}" ]] || fail "Expected no gh call, got: $(cat "${GH_ARGS_FILE}")" +} + +# The one gh invocation, compared whole: endpoint, method, body field, and jq filter. +assert_gh_args_equal() { + local expected="$1" actual + actual=$(cat "${GH_ARGS_FILE}") + [[ "${actual}" == "${expected}" ]] || fail "Expected gh args '${expected}', got: '${actual}'" +} + +# The last gh invocation, compared whole. +assert_gh_last_args_equal() { + local expected="$1" actual + actual=$(tail -n 1 "${GH_ARGS_FILE}") + [[ "${actual}" == "${expected}" ]] || fail "Expected last gh args '${expected}', got: '${actual}'" +} + +assert_no_rest_call() { + ! grep -q -- "-X " "${GH_ARGS_FILE}" || fail "Expected no REST call, got: $(cat "${GH_ARGS_FILE}")" +} + +assert_no_graphql_call() { + ! grep -q -- "graphql" "${GH_ARGS_FILE}" || fail "Expected no GraphQL call, got: $(cat "${GH_ARGS_FILE}")" +} + +@test "comment_edit patches an issue comment from an issues URL without a host prefix" { + GH_STUB_OUTPUT="https://github.com/shopware/shopware/issues/7#issuecomment-9001" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "New text"}' + assert_success + assert_output "https://github.com/shopware/shopware/issues/7#issuecomment-9001" + assert_gh_args_equal "api repos/shopware/shopware/issues/comments/9001 -X PATCH -f body=New text --jq .html_url // empty" +} + +@test "comment_edit patches a PR conversation comment through the issues comments endpoint" { + GH_STUB_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#issuecomment-9002", "body": "New text"}' + assert_success + assert_gh_args_equal "api repos/shopware/shopware/issues/comments/9002 -X PATCH -f body=New text --jq .html_url // empty" +} + +@test "comment_edit patches an inline review comment from a discussion_r anchor" { + GH_STUB_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "New text"}' + assert_success + assert_gh_last_args_equal "api repos/shopware/shopware/pulls/comments/5551 -X PATCH -f body=New text --jq .html_url // empty" +} + +@test "comment_edit patches an inline review comment from an r anchor on /files" { + GH_STUB_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "shopware/shopware/pull/100/files#r5552", "body": "New text"}' + assert_success + assert_gh_last_args_equal "api repos/shopware/shopware/pulls/comments/5552 -X PATCH -f body=New text --jq .html_url // empty" +} + +@test "comment_edit patches an inline review comment from an r anchor on /changes" { + GH_STUB_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "shopware/shopware/pull/100/changes#r5553", "body": "New text"}' + assert_success + assert_gh_last_args_equal "api repos/shopware/shopware/pulls/comments/5553 -X PATCH -f body=New text --jq .html_url // empty" +} + +@test "comment_edit puts a review body with PUT on the PR's reviews endpoint" { + GH_STUB_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-777", "body": "New summary"}' + assert_success + assert_gh_args_equal "api repos/shopware/shopware/pulls/100/reviews/777 -X PUT -f body=New summary --jq .html_url // empty" +} + +@test "comment_edit takes the repository from the URL, not from the default repo" { + GH_STUB_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "other-org/other.repo/issues/7#issuecomment-1", "body": "x"}' + assert_success + assert_gh_args_equal "api repos/other-org/other.repo/issues/comments/1 -X PATCH -f body=x --jq .html_url // empty" +} + +@test "comment_edit strips the http and www.github.com prefixes" { + GH_STUB_OUTPUT="https://example/c" + local prefix + for prefix in "http://github.com/" "https://www.github.com/"; do + : > "${GH_ARGS_FILE}" + run tool_comment_edit "{\"url\": \"${prefix}shopware/shopware/issues/7#issuecomment-3\", \"body\": \"x\"}" + assert_success + assert_gh_args_equal "api repos/shopware/shopware/issues/comments/3 -X PATCH -f body=x --jq .html_url // empty" + done +} + +@test "comment_edit passes a body with special characters as one -f argument" { + GH_STUB_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "a=b \"quoted\" $(x)"}' + assert_success + assert_gh_args_equal 'api repos/shopware/shopware/issues/comments/3 -X PATCH -f body=a=b "quoted" $(x) --jq .html_url // empty' +} + +@test "comment_edit requires url" { + run tool_comment_edit '{"body": "x"}' + assert_failure + assert_output --partial "url is required" + assert_gh_not_called +} + +@test "comment_edit requires body" { + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3"}' + assert_failure + assert_output --partial "body is required" + assert_gh_not_called +} + +@test "comment_edit rejects a URL on another host" { + run tool_comment_edit '{"url": "https://ghe.example.com/shopware/shopware/pull/1#issuecomment-5", "body": "x"}' + assert_failure + assert_output --partial "must be a github.com URL" + assert_gh_not_called +} + +@test "comment_edit rejects a bare host that is not github.com" { + run tool_comment_edit '{"url": "ghe.example.com/shopware/pull/1#issuecomment-5", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects a URL without an anchor" { + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects an unknown anchor" { + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#event-5", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects a non-numeric comment ID" { + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#issuecomment-abc", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects a non-numeric issue or PR number" { + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/abc#issuecomment-5", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects a review-comment anchor on an issues URL" { + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/issues/7#discussion_r5", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects an r anchor on an issues/N/files URL" { + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/issues/7/files#r5", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects a review anchor on an issues URL" { + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/issues/7#pullrequestreview-5", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects an r anchor without /files or /changes" { + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#r5", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects a discussion_r anchor on a /files URL" { + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100/files#discussion_r5", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects a review anchor on a /files URL" { + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100/files#pullrequestreview-5", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects a non-numeric review ID" { + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects extra path segments" { + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100/commits#issuecomment-5", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects a repository name ending in .git" { + run tool_comment_edit '{"url": "owner/repo.git/issues/1#issuecomment-2", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit rejects a repository named .." { + run tool_comment_edit '{"url": "shopware/../issues/7#issuecomment-5", "body": "x"}' + assert_failure + assert_output --partial "url must be" + assert_gh_not_called +} + +@test "comment_edit surfaces the gh error and fails when the request fails" { + GH_STUB_EXIT=1 + GH_STUB_OUTPUT='{"message":"Not Found","status":"404"}' + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' + assert_failure + assert_output --partial "Not Found" +} + +@test "comment_edit fails with a message when the REST call succeeds with no output" { + GH_STUB_OUTPUT="" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' + assert_failure + assert_output --partial "no URL for the edited comment" +} + +@test "comment_edit fails when the REST response has no html_url field" { + GH_STUB_APPLY_JQ=1 + GH_STUB_OUTPUT='{"id":3,"body":"x"}' + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' + assert_failure + assert_output --partial "no URL for the edited comment" + refute_output --partial "null" +} + +@test "comment_edit returns html_url from the REST response when the stub applies the filter" { + GH_STUB_APPLY_JQ=1 + GH_STUB_OUTPUT='{"id":3,"html_url":"https://example/c"}' + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' + assert_success + assert_output "https://example/c" +} + +@test "comment_edit names the failed call when the REST call fails with no output" { + GH_STUB_EXIT=3 + GH_STUB_OUTPUT="" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' + assert_failure 3 + assert_output --partial "gh api repos/shopware/shopware/issues/comments/3 failed with exit 3 and no output" +} + +PENDING_MATCH='{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"comments":{"nodes":[{"id":"PRRC_other","databaseId":4000},{"id":"PRRC_node5551","databaseId":5551}],"pageInfo":{"hasNextPage":false}}}]}}}}}' + +# Every argument of the last gh call, one array element each. +read_gh_argv() { + local n + n=$(find "${BATS_TEST_TMPDIR}" -maxdepth 1 -name 'gh_argv.*' | wc -l | tr -d ' ') + GH_ARGV=() + local a + while IFS= read -r -d '' a; do + GH_ARGV+=("$a") + done < "${BATS_TEST_TMPDIR}/gh_argv.${n}" +} + +# Exactly one argument equals the expected text. +assert_one_argv_equal() { + local expected="$1" arg count=0 + for arg in "${GH_ARGV[@]}"; do + [[ "${arg}" == "${expected}" ]] && count=$((count + 1)) + done + [[ ${count} -eq 1 ]] || fail "Expected exactly one argument equal to '${expected}', found ${count} in: $(printf '[%s] ' "${GH_ARGV[@]}")" +} + +TRICKY_BODY=$'-leading dash, two words a=b @file.txt "double" \'single\'\nsecond line' + +@test "comment_edit passes a body with a newline, spaces, =, @, quotes, and a leading dash as exactly one REST argument" { + GH_STUB_OUTPUT="https://example/c" + local json + json=$(jq -cn --arg b "${TRICKY_BODY}" '{url: "shopware/shopware/issues/7#issuecomment-3", body: $b}') + run tool_comment_edit "${json}" + assert_success + read_gh_argv + assert_one_argv_equal "body=${TRICKY_BODY}" +} + +@test "comment_edit passes the same body as exactly one argument to the GraphQL mutation" { + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":"https://example/u"}}}}' + local json + json=$(jq -cn --arg b "${TRICKY_BODY}" '{url: "https://github.com/shopware/shopware/pull/100#discussion_r5551", body: $b}') + run tool_comment_edit "${json}" + assert_success + assert_no_rest_call + read_gh_argv + assert_one_argv_equal "body=${TRICKY_BODY}" +} + +@test "comment_edit edits a comment in the pending review with the mutation and no REST call" { + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":"https://github.com/shopware/shopware/pull/100#discussion_r5551"}}}}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "New text"}' + assert_success + assert_output "https://github.com/shopware/shopware/pull/100#discussion_r5551" + assert_gh_args_contain "-f owner=shopware -f name=shopware -F number=100" + assert_gh_last_args_contain_all "updatePullRequestReviewComment" "-f id=PRRC_node5551" "-f body=New text" + assert_no_rest_call +} + +@test "comment_edit looks up the pending review for an r anchor on /files" { + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":"https://example/u"}}}}' + run tool_comment_edit '{"url": "shopware/shopware/pull/100/files#r5551", "body": "x"}' + assert_success + assert_output "https://example/u" + assert_no_rest_call +} + +@test "comment_edit fails when the GraphQL mutation succeeds with no output" { + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_OUTPUT="" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_output --partial "no URL for the edited comment" +} + +@test "comment_edit fails when the GraphQL mutation response has a null url" { + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":null}}}}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_output --partial "no URL for the edited comment" +} + +@test "comment_edit names the failed call when the GraphQL mutation fails with no output" { + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_OUTPUT="" + GH_STUB_MUTATION_EXIT=4 + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure 4 + assert_output --partial "gh api graphql failed with exit 4 and no output" +} + +@test "comment_edit fails when the pending-review lookup succeeds with no output" { + GH_STUB_LOOKUP_OUTPUT="" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_output --partial "pending-review lookup failed: GitHub returned no output" + assert_no_rest_call +} + +@test "comment_edit names the failed call when the pending-review lookup fails with no output" { + GH_STUB_LOOKUP_OUTPUT="" + GH_STUB_LOOKUP_EXIT=5 + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure 5 + assert_output --partial "gh api graphql failed with exit 5 and no output" + assert_no_rest_call +} + +assert_gh_last_args_contain_all() { + local last needle + last=$(tail -n 1 "${GH_ARGS_FILE}") + for needle in "$@"; do + [[ "${last}" == *"${needle}"* ]] || fail "Expected last gh call to contain '${needle}', got: ${last}" + done +} + +@test "comment_edit falls through to REST when the ID is not in the pending review" { + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_OUTPUT="https://github.com/shopware/shopware/pull/100#discussion_r9999" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r9999", "body": "x"}' + assert_success + assert_output "https://github.com/shopware/shopware/pull/100#discussion_r9999" + assert_gh_last_args_equal "api repos/shopware/shopware/pulls/comments/9999 -X PATCH -f body=x --jq .html_url // empty" + ! grep -q "updatePullRequestReviewComment" "${GH_ARGS_FILE}" +} + +@test "comment_edit falls through to REST when the caller has no pending review" { + GH_STUB_OUTPUT="https://github.com/shopware/shopware/pull/100#discussion_r5551" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_success + assert_gh_last_args_equal "api repos/shopware/shopware/pulls/comments/5551 -X PATCH -f body=x --jq .html_url // empty" +} + +@test "comment_edit errors without any further call when the pending-review lookup fails" { + GH_STUB_LOOKUP_EXIT=1 + GH_STUB_LOOKUP_OUTPUT="gh: Could not resolve to a Repository" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_output --partial "pending-review lookup failed" + assert_output --partial "Could not resolve to a Repository" + [[ $(wc -l < "${GH_ARGS_FILE}") -eq 1 ]] || fail "Expected exactly the lookup call, got: $(cat "${GH_ARGS_FILE}")" +} + +@test "comment_edit errors when the lookup response carries GraphQL errors" { + GH_STUB_LOOKUP_OUTPUT='{"errors":[{"message":"Something broke"}],"data":null}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_output --partial "pending-review lookup failed" + assert_output --partial "Something broke" + [[ $(wc -l < "${GH_ARGS_FILE}") -eq 1 ]] || fail "Expected exactly the lookup call, got: $(cat "${GH_ARGS_FILE}")" +} + +@test "comment_edit errors without a REST call when the mutation exits non-zero" { + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_EXIT=1 + GH_STUB_MUTATION_OUTPUT="gh: Resource not accessible" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_output --partial "Resource not accessible" + assert_no_rest_call +} + +@test "comment_edit errors without a REST call when the mutation response carries errors" { + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_OUTPUT='{"errors":[{"message":"Body cannot be blank"}]}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_output --partial "Body cannot be blank" + assert_no_rest_call +} + +@test "comment_edit errors without a REST call when the pending review has more comments than the lookup page and no match" { + GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"comments":{"nodes":[{"id":"PRRC_a","databaseId":1}],"pageInfo":{"hasNextPage":true}}}]}}}}}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_output --partial "more than 100 comments" + assert_no_rest_call +} + +@test "comment_edit edits a matching comment even when the pending review has further pages" { + GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"comments":{"nodes":[{"id":"PRRC_node5551","databaseId":5551}],"pageInfo":{"hasNextPage":true}}}]}}}}}' + GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":"https://example/u"}}}}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_success + assert_output "https://example/u" +} + +@test "comment_edit makes no GraphQL call for issue-comment and review-summary anchors" { + GH_STUB_OUTPUT="https://example/c" + local url + for url in "shopware/shopware/issues/7#issuecomment-3" "shopware/shopware/pull/100#issuecomment-3" "shopware/shopware/pull/100#pullrequestreview-777"; do + : > "${GH_ARGS_FILE}" + run tool_comment_edit "{\"url\": \"${url}\", \"body\": \"x\"}" + assert_success + assert_no_graphql_call + [[ $(wc -l < "${GH_ARGS_FILE}") -eq 1 ]] || fail "Expected exactly one REST call for ${url}, got: $(cat "${GH_ARGS_FILE}")" + done +} diff --git a/plugins/github-mcp/AGENTS.md b/plugins/github-mcp/AGENTS.md index 69e7f34..fd14381 100644 --- a/plugins/github-mcp/AGENTS.md +++ b/plugins/github-mcp/AGENTS.md @@ -54,7 +54,7 @@ plugins/github-mcp/ ├── issue_schema.sh # tool_issue_schema() (org issue types + issue fields, name filters) ├── issue_write.sh # tool_issue_create/edit/close/reopen/comment() ├── issue_schema_write.sh # tool_issue_type_set(), tool_issue_field_set() (name-to-ID resolution, PUT replace) - ├── review_write.sh # tool_pr_review_submit(), tool_pr_comment(), tool_pr_review_reply() + ├── review_write.sh # tool_pr_review_submit(), tool_pr_comment(), tool_pr_review_reply(), tool_comment_edit() ├── run.sh # tool_run_view(), tool_run_list(), tool_run_logs(), tool_workflow_jobs() ├── job.sh # tool_job_view(), tool_job_logs(), tool_job_annotations() ├── commit.sh # tool_commit_pulls() @@ -125,7 +125,7 @@ Claude Code / Codex ← stdout ← JSON-RPC response ← formatted output Tools in `tools-read.json` and `tools-write.json` map to bash functions with `tool_` prefix: - Uses bash arrays (`local -a cmd=("gh" "pr" "view" "${number}")`) for injection-safe argument passing - `_gh_resolve_repo()` falls back to `GH_DEFAULT_REPO` from config -- All tools support `suppress_errors` and `fallback` shared parameters +- All tools support `suppress_errors` and `fallback` shared parameters, except write tool `comment_edit`, which declares neither so a failed edit always returns an error - Tools with JSON output support `jq_filter` with pre-execution syntax validation - Tools that accept `grep_pattern` validate it with `_gh_validate_grep_pattern()` before calling `gh` - Log/text tools support `max_lines`, `tail_lines`, and grep parameters @@ -254,7 +254,7 @@ own unit tests are Node tests under `plugin-tests/github-mcp/pi/`: | `write_tools_issue_schema.bats` | `issue_type_set` and `issue_field_set` name resolution and value checks | | `write_tools_pr.bats` | `pr_create`/`edit`/`ready`/`merge`/`close`/`reopen` parameter handling | | `write_tools_project.bats` | `project_item_add` and `project_status_set` name-to-ID resolution | -| `write_tools_review.bats` | `pr_review_submit`/`pr_comment`/`pr_review_reply` parameter handling and REST payloads | +| `write_tools_review.bats` | `pr_review_submit`/`pr_comment`/`pr_review_reply`/`comment_edit` parameter handling and REST payloads, including `comment_edit`'s URL forms and rejections | | `pi/gate.test.ts` | `runGate()`/`runScript()`: exit codes, stderr capture, stdin handling, and the timeout that kills a gate's background children | | `pi/index.test.ts` | The extension's event handlers on a fake pi: the `github_mcp` section from session-start output (malformed output and non-zero exits add none), and which `bash` and `api_read` calls the gates block or let through | | `pi/tool-search-ranking.test.ts` | Exact-name ranking with pi's own ranker over the real tool catalogs, server descriptions, and server instructions; and that no server description or instructions term matches a tool-name term | diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index 7ee26f2..4d71f23 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `comment_edit` write tool. Replaces the body of one existing comment, whatever its kind, from the + comment's URL: `issues/N#issuecomment-ID` and `pull/N#issuecomment-ID` (conversation comments), + `pull/N#discussion_rID` and `pull/N/files#rID` or `pull/N/changes#rID` (inline review comments), + and `pull/N#pullrequestreview-ID` (a review's summary body). The URL may carry a leading + `https://github.com/`; a URL on another host, without an anchor, with an unknown anchor, or with a + non-numeric ID or number is rejected before any `gh` call. The two inline review comment forms + also edit a comment in the caller's own unsubmitted (pending) review, which the REST endpoint + rejects with 404: the tool looks up the pending review through GraphQL and edits a matching + comment with the `updatePullRequestReviewComment` mutation. Every failure is returned as an + error and never falls back to another route. It returns the updated comment's `html_url`, or the + `url` for a comment in a pending review. +- `check-api-tools.sh` redirects `PATCH repos/O/R/issues/comments/ID`, + `PATCH repos/O/R/pulls/comments/ID`, and `PUT repos/O/R/pulls/N/reviews/ID` on the `api` write tool + to `comment_edit`, under `block_api_tool_write`. The block messages for `gh pr comment` and + `gh issue comment` now also name `comment_edit` for editing an existing comment, which covers + `--edit-last`. + ## [5.0.1] - 2026-10-03 ### Fixed diff --git a/plugins/github-mcp/README.md b/plugins/github-mcp/README.md index 36f48d8..fd917e7 100644 --- a/plugins/github-mcp/README.md +++ b/plugins/github-mcp/README.md @@ -22,7 +22,7 @@ GitHub CLI tools via MCP (Model Context Protocol). Wraps the `gh` CLI for pull r ### Write Server (gh-tooling-write) - **PR lifecycle** via `pr_create`, `pr_edit`, `pr_ready`, `pr_merge`, `pr_close`, `pr_reopen` -- **Reviews** via `pr_review_submit`, `pr_comment`, `pr_review_reply` +- **Reviews** via `pr_review_submit`, `pr_comment`, `pr_review_reply`, `comment_edit` (edits any existing comment by its URL) - **Issue lifecycle** via `issue_create`, `issue_edit`, `issue_close`, `issue_reopen`, `issue_comment` - **Labels** via `label_add`, `label_remove` - **Assignees** via `assignee_add`, `assignee_remove` @@ -158,7 +158,7 @@ Configuration is loaded in the following priority order: ## Tools Reference -31 read tools + 25 write tools organized by category. See [REFERENCE.md](./REFERENCE.md) for full parameter docs and examples. +31 read tools + 26 write tools organized by category. See [REFERENCE.md](./REFERENCE.md) for full parameter docs and examples. ### Read Server (gh-tooling) -- 31 tools @@ -177,12 +177,12 @@ Configuration is loaded in the following priority order: | Projects | `project_list`, `project_view` | | Raw API | `api_read` (GET only) | -### Write Server (gh-tooling-write) -- 25 tools +### Write Server (gh-tooling-write) -- 26 tools | Category | Tools | |--------------|------------------------------------------------------------------------------| | PR lifecycle | `pr_create`, `pr_edit`, `pr_ready`, `pr_merge`, `pr_close`, `pr_reopen` | -| Reviews | `pr_review_submit`, `pr_comment`, `pr_review_reply` | +| Reviews | `pr_review_submit`, `pr_comment`, `pr_review_reply`, `comment_edit` | | Issues | `issue_create`, `issue_edit`, `issue_close`, `issue_reopen`, `issue_comment` | | Issue schema | `issue_type_set`, `issue_field_set` | | Labels | `label_add`, `label_remove` | @@ -295,12 +295,12 @@ To allow direct CLI invocations, set `enforce_mcp_tools` to `false` in your conf | `gh pr close` | `pr_close` | | `gh pr reopen` | `pr_reopen` | | `gh pr review` | `pr_review_submit` | -| `gh pr comment` | `pr_comment` | +| `gh pr comment` | `pr_comment` / `comment_edit` | | `gh issue create` | `issue_create` | | `gh issue edit` | `issue_edit` | | `gh issue close` | `issue_close` | | `gh issue reopen` | `issue_reopen` | -| `gh issue comment` | `issue_comment` | +| `gh issue comment` | `issue_comment` / `comment_edit` | | `gh project item-add` | `project_item_add` | | `gh project item-edit` | `project_status_set` | diff --git a/plugins/github-mcp/REFERENCE.md b/plugins/github-mcp/REFERENCE.md index 8d97963..0d39ac9 100644 --- a/plugins/github-mcp/REFERENCE.md +++ b/plugins/github-mcp/REFERENCE.md @@ -575,7 +575,7 @@ Tools available via the `gh-tooling-write` MCP server. Requires `enable_write_se ### Shared Tool Parameters -All gh-tooling-write MCP tools accept these parameters: +All gh-tooling-write MCP tools except `comment_edit` accept these parameters. `comment_edit` declares neither, so a failed edit always returns an error: | Parameter | Type | Default | Description | |-------------------|---------|---------|-------------------------------------------------------------------------| @@ -733,6 +733,33 @@ Use gh-tooling-write pr_review_reply with number 14642 and comment_id 1234567 an - `body` (string, required): Reply body text. - `repo` (string, optional): Repository in `owner/repo` format. +#### `comment_edit` + +Replace the body of one existing comment, whatever its kind: an issue or PR conversation comment, an inline review comment, or a review's summary body. The kind comes from the anchor of `url`, so pass the URL exactly as `issue_comment` or `pr_comment` returned it, or as the `html_url` from `pr_comments`, `pr_reviews`, or `pr_review_reply`. The body replaces the whole comment text; send the full new text, not a diff. Returns the updated comment's `html_url`. + +``` +Use gh-tooling-write comment_edit with url "https://github.com/shopware/shopware/pull/14642#issuecomment-2001" and body "CI is green, ready to merge" +Use gh-tooling-write comment_edit with url "shopware/shopware/pull/14642#discussion_r1234567" and body "Fixed in b8f9a8c." +``` + +Accepted `url` forms, each with or without a leading `https://github.com/` (`http://github.com/` and `https://www.github.com/` also work; any other scheme or host is rejected). `N` and `ID` are digits only: + +| `url` form | Edits | Request | +|--------------------------------------------------------|---------------------------------|-------------------------------------------------| +| `OWNER/REPO/issues/N#issuecomment-ID` | Issue conversation comment | `PATCH repos/OWNER/REPO/issues/comments/ID` | +| `OWNER/REPO/pull/N#issuecomment-ID` | PR conversation comment | `PATCH repos/OWNER/REPO/issues/comments/ID` | +| `OWNER/REPO/pull/N#discussion_rID` | Inline review comment | `PATCH repos/OWNER/REPO/pulls/comments/ID` (see below) | +| `OWNER/REPO/pull/N/files#rID` or `.../changes#rID` | Inline review comment | `PATCH repos/OWNER/REPO/pulls/comments/ID` (see below) | +| `OWNER/REPO/pull/N#pullrequestreview-ID` | Review summary body | `PUT repos/OWNER/REPO/pulls/N/reviews/ID` | + +The two inline review comment forms also cover a comment in your own unsubmitted (pending) review, which the REST endpoint answers with 404. The tool first looks up your pending review on the PR through GraphQL. If the comment is in it, the tool edits it with the `updatePullRequestReviewComment` mutation and returns the comment's `url`. If you have no pending review, or the comment is not in it, the tool sends the `PATCH` shown. Every failure is returned as an error: a failed lookup, a failed mutation, or a pending review of more than 100 comments in which the comment is not found. None of these falls back to the `PATCH`. + +Any other URL fails before the request: no anchor, an unknown anchor, a non-numeric number or ID, `#discussion_rID`, `#rID`, or `#pullrequestreview-ID` on an `issues/N` URL, `#rID` without `/files` or `/changes`, and extra path segments or a query string. The repository comes from the URL, so the tool takes no `repo` parameter. + +**Parameters:** +- `url` (string, required): URL of the comment to edit, with its anchor. +- `body` (string, required): New comment text. Replaces the whole existing text. + ### Issue Write Tools #### `issue_create` diff --git a/plugins/github-mcp/SETUP.md b/plugins/github-mcp/SETUP.md index 09c1945..833dfd0 100644 --- a/plugins/github-mcp/SETUP.md +++ b/plugins/github-mcp/SETUP.md @@ -104,11 +104,12 @@ ### PR reviews and comments - **Recommended**: ask - **Optional**: Yes (skip if `enable_write_server` is `false` or the user declined to enable the write server in Phase 3) -- **Description**: Posting PR reviews (with optional inline code comments), top-level PR comments, and threaded replies to review comments. Lower-risk than PR lifecycle changes but still visible to other contributors. +- **Description**: Posting PR reviews (with optional inline code comments), top-level PR comments, threaded replies to review comments, and editing an existing comment. Lower-risk than PR lifecycle changes but still visible to other contributors. - **Patterns**: - `mcp__plugin_github-mcp_gh-tooling-write__pr_review_submit` - `mcp__plugin_github-mcp_gh-tooling-write__pr_comment` - `mcp__plugin_github-mcp_gh-tooling-write__pr_review_reply` + - `mcp__plugin_github-mcp_gh-tooling-write__comment_edit` ### Issue write tools - **Recommended**: ask diff --git a/plugins/github-mcp/hooks/prompts/write-operations-enabled.md b/plugins/github-mcp/hooks/prompts/write-operations-enabled.md index b96217b..90de414 100644 --- a/plugins/github-mcp/hooks/prompts/write-operations-enabled.md +++ b/plugins/github-mcp/hooks/prompts/write-operations-enabled.md @@ -1,6 +1,6 @@ ## Write (gh-tooling-write) PRs: pr_create, pr_edit, pr_ready, pr_merge, pr_close, pr_reopen -Reviews: pr_review_submit, pr_comment, pr_review_reply +Reviews: pr_review_submit, pr_comment, pr_review_reply, comment_edit (replaces the body of one existing issue, PR, review, or inline review comment, found by its url) Issues: issue_create, issue_edit, issue_close, issue_reopen, issue_comment Issue type and fields: issue_type_set, issue_field_set (issue_field_set replaces the issue's whole set of field values; read them first with issue_view and with_field_values true) Labels: label_add, label_remove diff --git a/plugins/github-mcp/hooks/scripts/check-api-tools.sh b/plugins/github-mcp/hooks/scripts/check-api-tools.sh index 85a0b3a..0c4fac7 100755 --- a/plugins/github-mcp/hooks/scripts/check-api-tools.sh +++ b/plugins/github-mcp/hooks/scripts/check-api-tools.sh @@ -166,6 +166,16 @@ if [[ "$IS_WRITE" == "true" ]]; then block_tool "issue_edit or issue_type_set" "Use issue_edit for title, body, labels, and assignees, or issue_type_set for the issue type." fi + # Edit an existing comment: issue/PR conversation comment, inline review comment, + # review body. The anchored comment URL is the tool's input. + if [[ "$METHOD" == "PATCH" ]] && echo "$ENDPOINT_PATH" | grep -qE '(^|/)repos/[^/]+/[^/]+/(issues|pulls)/comments/[0-9]+/?$'; then + block_tool "comment_edit" "Use comment_edit with the comment's url (the one issue_comment or pr_comment returned, or the html_url from pr_comments or pr_reviews) and the new body." + fi + + if [[ "$METHOD" == "PUT" ]] && echo "$ENDPOINT_PATH" | grep -qE '(^|/)repos/[^/]+/[^/]+/pulls/[0-9]+/reviews/[0-9]+/?$'; then + block_tool "comment_edit" "Use comment_edit with the review's html_url (from pr_reviews, ending in #pullrequestreview-ID) and the new body." + fi + # PR review comment thread replies (POST) if [[ "$METHOD" == "POST" ]] && echo "$ENDPOINT" | grep -qE 'pulls/[0-9]+/comments/[0-9]+/replies$'; then block_tool "pr_review_reply" "Use pr_review_reply with number, comment_id, and body." diff --git a/plugins/github-mcp/hooks/scripts/check-gh-tools.sh b/plugins/github-mcp/hooks/scripts/check-gh-tools.sh index 552e76a..9f142d3 100644 --- a/plugins/github-mcp/hooks/scripts/check-gh-tools.sh +++ b/plugins/github-mcp/hooks/scripts/check-gh-tools.sh @@ -145,7 +145,7 @@ fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*gh\s+pr\s+comment(\s|$)'; then block_tool "mcp__gh-tooling-write__pr_comment" \ - "Use pr_comment with number and body parameters." + "Use pr_comment with number and body parameters. To edit an existing comment (gh pr comment --edit-last), use comment_edit with the comment's url and the new body." fi # ============================================================================ @@ -174,7 +174,7 @@ fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*gh\s+issue\s+comment(\s|$)'; then block_tool "mcp__gh-tooling-write__issue_comment" \ - "Use issue_comment with number and body parameters." + "Use issue_comment with number and body parameters. To edit an existing comment (gh issue comment --edit-last), use comment_edit with the comment's url and the new body." fi # ============================================================================ diff --git a/plugins/github-mcp/mcp-server-gh/lib/review_write.sh b/plugins/github-mcp/mcp-server-gh/lib/review_write.sh index 33e0821..7593145 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/review_write.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/review_write.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Review write tools for gh-tooling MCP server (write operations) -# Tools: pr_review_submit, pr_comment, pr_review_reply +# Tools: pr_review_submit, pr_comment, pr_review_reply, comment_edit # Submit a review on a pull request, optionally with inline comments. # @@ -230,3 +230,161 @@ tool_pr_review_reply() { fi echo "${__raw}" } + +# Replace the body of one existing comment, whatever its kind. The kind comes +# from the URL anchor, and a URL that matches none of the forms below fails +# before any gh call: +# OWNER/REPO/issues/N#issuecomment-ID → PATCH repos/O/R/issues/comments/ID +# OWNER/REPO/pull/N#issuecomment-ID → PATCH repos/O/R/issues/comments/ID +# OWNER/REPO/pull/N#discussion_rID → PATCH repos/O/R/pulls/comments/ID +# OWNER/REPO/pull/N/files|changes#rID → PATCH repos/O/R/pulls/comments/ID +# OWNER/REPO/pull/N#pullrequestreview-ID → PUT repos/O/R/pulls/N/reviews/ID +# For the two review-comment forms, the caller's own pending (unsubmitted) +# review is looked up first, because REST answers 404 for a comment inside it. +# A match is edited with the updatePullRequestReviewComment GraphQL mutation; +# no match falls through to the PATCH above. A failed lookup, a failed +# mutation, or a pending review of more than 100 comments with no match is an +# error and makes no further call. +# A leading https://github.com/, http://github.com/, or https://www.github.com/ +# is stripped; any other scheme or host is rejected. +tool_comment_edit() { + local args="$1" + + local url body + url=$(echo "${args}" | jq -r '.url // empty') + body=$(echo "${args}" | jq -r '.body // empty') + + if [[ -z "${url}" ]]; then + echo "Error: url is required for comment_edit" + return 1 + fi + + if [[ -z "${body}" ]]; then + echo "Error: body is required for comment_edit" + return 1 + fi + + local rest="${url}" + case "${rest}" in + https://github.com/*) rest="${rest#https://github.com/}" ;; + http://github.com/*) rest="${rest#http://github.com/}" ;; + https://www.github.com/*) rest="${rest#https://www.github.com/}" ;; + esac + + if [[ "${rest}" =~ ^[A-Za-z][A-Za-z0-9+.-]*:// ]]; then + echo "Error: url must be a github.com URL, got: '${url}'" + return 1 + fi + + local form_error="Error: url must be OWNER/REPO/issues/N#issuecomment-ID, OWNER/REPO/pull/N#issuecomment-ID, OWNER/REPO/pull/N#discussion_rID, OWNER/REPO/pull/N/files#rID, OWNER/REPO/pull/N/changes#rID, or OWNER/REPO/pull/N#pullrequestreview-ID (optionally with a leading https://github.com/), got: '${url}'" + local url_re='^([A-Za-z0-9-]+/[A-Za-z0-9_.-]+)/(issues|pull)/([0-9]+)(/files|/changes)?#([A-Za-z0-9_-]+)$' + if [[ ! "${rest}" =~ ${url_re} ]]; then + echo "${form_error}" + return 1 + fi + local repo="${BASH_REMATCH[1]}" kind="${BASH_REMATCH[2]}" number="${BASH_REMATCH[3]}" + local suffix="${BASH_REMATCH[4]}" anchor="${BASH_REMATCH[5]}" + if [[ "${repo##*/}" == "." || "${repo##*/}" == ".." || "${repo##*/}" == *.git ]]; then + echo "${form_error}" + return 1 + fi + + local endpoint method review_comment_id="" + if [[ "${anchor}" =~ ^issuecomment-([0-9]+)$ && -z "${suffix}" ]]; then + endpoint="repos/${repo}/issues/comments/${BASH_REMATCH[1]}" + method="PATCH" + elif [[ "${kind}" == "pull" && -z "${suffix}" && "${anchor}" =~ ^discussion_r([0-9]+)$ ]]; then + endpoint="repos/${repo}/pulls/comments/${BASH_REMATCH[1]}" + method="PATCH" + review_comment_id="${BASH_REMATCH[1]}" + elif [[ "${kind}" == "pull" && -n "${suffix}" && "${anchor}" =~ ^r([0-9]+)$ ]]; then + endpoint="repos/${repo}/pulls/comments/${BASH_REMATCH[1]}" + method="PATCH" + review_comment_id="${BASH_REMATCH[1]}" + elif [[ "${kind}" == "pull" && -z "${suffix}" && "${anchor}" =~ ^pullrequestreview-([0-9]+)$ ]]; then + endpoint="repos/${repo}/pulls/${number}/reviews/${BASH_REMATCH[1]}" + method="PUT" + else + echo "${form_error}" + return 1 + fi + + local __raw __exit=0 + + if [[ -n "${review_comment_id}" ]]; then + local owner="${repo%%/*}" name="${repo##*/}" + local -a lookup_cmd=("gh" "api" "graphql" + "-f" "query=query(\$owner: String!, \$name: String!, \$number: Int!) { repository(owner: \$owner, name: \$name) { pullRequest(number: \$number) { reviews(states: [PENDING], first: 1) { nodes { comments(first: 100) { nodes { id databaseId } pageInfo { hasNextPage } } } } } } }" + "-f" "owner=${owner}" "-f" "name=${name}" "-F" "number=${number}" + ) + + log "INFO" "comment_edit: pending-review lookup: ${lookup_cmd[*]}" + __raw=$("${lookup_cmd[@]}" 2>&1) || __exit=$? + local lookup_error="" + if [[ ${__exit} -ne 0 ]]; then + lookup_error="${__raw:-gh api graphql failed with exit ${__exit} and no output}" + elif [[ -z "${__raw}" ]]; then + lookup_error="GitHub returned no output" + elif ! lookup_error=$(printf '%s' "${__raw}" | jq -r 'if (.errors // empty) then "GraphQL errors: \(.errors | tostring)" elif (.data.repository.pullRequest | type) != "object" then "response has no pull request data" else empty end' 2>&1); then + lookup_error="unparseable response: ${__raw}" + fi + if [[ -n "${lookup_error}" || ${__exit} -ne 0 ]]; then + [[ "${__exit}" -ne 0 ]] || __exit=1 + echo "Error: pending-review lookup failed: ${lookup_error}" + return ${__exit} + fi + + local node_id has_next + node_id=$(printf '%s' "${__raw}" | jq -r --argjson id "${review_comment_id}" \ + '[.data.repository.pullRequest.reviews.nodes[0].comments.nodes[]? | select(.databaseId == $id) | .id][0] // empty') + has_next=$(printf '%s' "${__raw}" | jq -r '.data.repository.pullRequest.reviews.nodes[0].comments.pageInfo.hasNextPage // false') + + if [[ -n "${node_id}" ]]; then + local -a mutation_cmd=("gh" "api" "graphql" + "-f" "query=mutation(\$id: ID!, \$body: String!) { updatePullRequestReviewComment(input: {pullRequestReviewCommentId: \$id, body: \$body}) { pullRequestReviewComment { url } } }" + "-f" "id=${node_id}" "-f" "body=${body}" + ) + log "INFO" "comment_edit: pending-review mutation: ${mutation_cmd[*]}" + __exit=0 + __raw=$("${mutation_cmd[@]}" 2>&1) || __exit=$? + local mutation_error="" mutation_url="" + if [[ ${__exit} -ne 0 ]]; then + mutation_error="${__raw:-gh api graphql failed with exit ${__exit} and no output}" + elif [[ -z "${__raw}" ]]; then + mutation_error="GitHub returned no URL for the edited comment" + elif ! mutation_error=$(printf '%s' "${__raw}" | jq -r 'if (.errors // empty) then "GraphQL errors: \(.errors | tostring)" else empty end' 2>&1); then + mutation_error="unparseable response: ${__raw}" + elif [[ -z "${mutation_error}" ]]; then + mutation_url=$(printf '%s' "${__raw}" | jq -r '.data.updatePullRequestReviewComment.pullRequestReviewComment.url // empty') + [[ -n "${mutation_url}" && "${mutation_url}" != "null" ]] || mutation_error="GitHub returned no URL for the edited comment: ${__raw}" + fi + if [[ -n "${mutation_error}" ]]; then + [[ "${__exit}" -ne 0 ]] || __exit=1 + echo "Error: pending-review comment edit failed: ${mutation_error}" + return ${__exit} + fi + echo "${mutation_url}" + return 0 + fi + + if [[ "${has_next}" == "true" ]]; then + echo "Error: the pending review has more than 100 comments and comment ${review_comment_id} could not be located" + return 1 + fi + __exit=0 + fi + + local -a cmd=("gh" "api" "${endpoint}" "-X" "${method}" "-f" "body=${body}" "--jq" ".html_url // empty") + + log "INFO" "comment_edit: ${cmd[*]}" + __raw=$("${cmd[@]}" 2>&1) || __exit=$? + if [[ ${__exit} -ne 0 ]]; then + echo "${__raw:-comment_edit: gh api ${endpoint} failed with exit ${__exit} and no output}" + return ${__exit} + fi + if [[ -z "${__raw}" || "${__raw}" == "null" ]]; then + echo "Error: comment_edit: GitHub returned no URL for the edited comment" + return 1 + fi + echo "${__raw}" +} diff --git a/plugins/github-mcp/mcp-server-gh/tools-write.json b/plugins/github-mcp/mcp-server-gh/tools-write.json index c1a021a..817b830 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-write.json +++ b/plugins/github-mcp/mcp-server-gh/tools-write.json @@ -755,6 +755,25 @@ "additionalProperties": false } }, + { + "name": "comment_edit", + "description": "Replace the body of an issue or pull request comment, an inline comment on a diff (including one in your own unsubmitted draft), or a summary of a submitted verdict. The new body replaces the whole text. Accepted url forms, with or without a leading https://github.com/: OWNER/REPO/issues/N#issuecomment-ID, OWNER/REPO/pull/N#issuecomment-ID, OWNER/REPO/pull/N#discussion_rID, OWNER/REPO/pull/N/files#rID (or /changes#rID), OWNER/REPO/pull/N#pullrequestreview-ID. Any other url is rejected before the request. Returns the html_url.", + "inputSchema": { + "type": "object", + "required": ["url", "body"], + "properties": { + "url": { + "type": "string", + "description": "URL of the comment to edit, with its anchor, for example https://github.com/OWNER/REPO/pull/123#discussion_r456." + }, + "body": { + "type": "string", + "description": "New comment text. Replaces the whole existing text." + } + }, + "additionalProperties": false + } + }, { "name": "project_item_add", "description": "Add an issue or PR to a GitHub Project by project name. The server resolves the project name to its ID.", diff --git a/plugins/plugin-setup/skills/github-mcp-setting-up/references/plugin-setup.md b/plugins/plugin-setup/skills/github-mcp-setting-up/references/plugin-setup.md index 09c1945..833dfd0 100644 --- a/plugins/plugin-setup/skills/github-mcp-setting-up/references/plugin-setup.md +++ b/plugins/plugin-setup/skills/github-mcp-setting-up/references/plugin-setup.md @@ -104,11 +104,12 @@ ### PR reviews and comments - **Recommended**: ask - **Optional**: Yes (skip if `enable_write_server` is `false` or the user declined to enable the write server in Phase 3) -- **Description**: Posting PR reviews (with optional inline code comments), top-level PR comments, and threaded replies to review comments. Lower-risk than PR lifecycle changes but still visible to other contributors. +- **Description**: Posting PR reviews (with optional inline code comments), top-level PR comments, threaded replies to review comments, and editing an existing comment. Lower-risk than PR lifecycle changes but still visible to other contributors. - **Patterns**: - `mcp__plugin_github-mcp_gh-tooling-write__pr_review_submit` - `mcp__plugin_github-mcp_gh-tooling-write__pr_comment` - `mcp__plugin_github-mcp_gh-tooling-write__pr_review_reply` + - `mcp__plugin_github-mcp_gh-tooling-write__comment_edit` ### Issue write tools - **Recommended**: ask From 1445fd5182b146789b421df8952b5aebcaf10a41 Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Tue, 6 Oct 2026 16:26:26 +0200 Subject: [PATCH 2/5] fix(github-mcp): check comment ownership and read before editing `comment_edit` used the issue or PR number in the URL only to build the review-summary endpoint, so `issues/7#issuecomment-555` edited comment 555 even when it belonged to issue 99. The tool now reads a conversation or inline comment first and edits it only when its `issue_url` ends in `/issues/N` or its `pull_request_url` in `/pulls/N`. Otherwise it errors and writes nothing. The read also picks the route for inline comments. The previous version queried the pending review over GraphQL before every inline edit and failed hard on a lookup error or on more than 100 pending comments, which blocked edits of submitted comments that REST alone would complete. Now a successful read leads to the REST `PATCH`, and only a 404 leads to the pending-review lookup. The lookup matches `fullDatabaseId` as a string, takes the caller's own review via `viewerDidAuthor` among up to 100 pending reviews, and errors when nothing matches. Every gh call captures stdout and stderr apart through `_gh_capture_split`, so a stderr warning on a successful mutation no longer reports an applied edit as failed. One helper, `_comment_edit_graphql`, classifies GraphQL failures for the lookup and the mutation, and every error starts with `Error:`. The `check-api-tools.sh` message for `PATCH issues/comments/ID` pointed to `pr_comments`, which never returns conversation comments. It now points to `issue_view` or `pr_view` with `fields=comments`. Three tests that only checked a static prompt or message for a word are removed. Co-Authored-By: Claude --- plugin-tests/github-mcp/check_api_tools.bats | 9 + plugin-tests/github-mcp/gh_tools_write.bats | 6 - .../github-mcp/session_start_write.bats | 8 - .../github-mcp/write_tools_review.bats | 800 +++++++++++++----- plugins/github-mcp/CHANGELOG.md | 22 +- plugins/github-mcp/REFERENCE.md | 8 +- .../hooks/scripts/check-api-tools.sh | 8 +- .../mcp-server-gh/lib/review_write.sh | 215 +++-- 8 files changed, 751 insertions(+), 325 deletions(-) diff --git a/plugin-tests/github-mcp/check_api_tools.bats b/plugin-tests/github-mcp/check_api_tools.bats index 78b9dee..aa1febf 100644 --- a/plugin-tests/github-mcp/check_api_tools.bats +++ b/plugin-tests/github-mcp/check_api_tools.bats @@ -264,11 +264,20 @@ setup_write_blocking() { refute_output --partial "issue_edit" } +@test "write api: the PATCH issues/comments/ID block takes the url from issue_view or pr_view comments, not pr_comments" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/issues/comments/456" "PATCH" + assert_failure 2 + assert_output --partial "issue_view or pr_view with fields=comments" + refute_output --partial "pr_comments" +} + @test "write api: blocks PATCH pulls/comments/ID → suggests comment_edit" { setup_write_blocking run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/pulls/comments/456" "PATCH" assert_failure 2 assert_output --partial "comment_edit" + assert_output --partial "pr_comments" } @test "write api: blocks PUT pulls/N/reviews/ID → suggests comment_edit" { diff --git a/plugin-tests/github-mcp/gh_tools_write.bats b/plugin-tests/github-mcp/gh_tools_write.bats index 6e12dd1..69fd82b 100644 --- a/plugin-tests/github-mcp/gh_tools_write.bats +++ b/plugin-tests/github-mcp/gh_tools_write.bats @@ -26,9 +26,6 @@ bats_test_function --description "blocks gh pr review → suggests pr_review_sub bats_test_function --description "blocks gh pr comment → suggests pr_comment" \ -- gh_hook_blocks "gh pr comment 100 --body 'lgtm'" "pr_comment" -bats_test_function --description "blocks gh pr comment --edit-last → also suggests comment_edit" \ - -- gh_hook_blocks "gh pr comment 100 --edit-last --body 'lgtm'" "comment_edit" - # Issue write commands bats_test_function --description "blocks gh issue create → suggests issue_create" \ -- gh_hook_blocks "gh issue create --title 'bug'" "issue_create" @@ -41,9 +38,6 @@ bats_test_function --description "blocks gh issue reopen → suggests issue_reop bats_test_function --description "blocks gh issue comment → suggests issue_comment" \ -- gh_hook_blocks "gh issue comment 50 --body 'fixed'" "issue_comment" -bats_test_function --description "blocks gh issue comment --edit-last → also suggests comment_edit" \ - -- gh_hook_blocks "gh issue comment 50 --edit-last --body 'fixed'" "comment_edit" - # Label commands bats_test_function --description "blocks gh label list → suggests label_list" \ -- gh_hook_blocks "gh label list --repo shopware/shopware" "label_list" diff --git a/plugin-tests/github-mcp/session_start_write.bats b/plugin-tests/github-mcp/session_start_write.bats index a5334c6..109d958 100644 --- a/plugin-tests/github-mcp/session_start_write.bats +++ b/plugin-tests/github-mcp/session_start_write.bats @@ -23,14 +23,6 @@ extract_context() { [[ "$context" == *"pr_create"* ]] } -@test "write section lists comment_edit" { - run_session_with_config '{"enforce_mcp_tools": true, "enable_write_server": true}' - assert_success - local context - context=$(extract_context) - [[ "$context" == *"comment_edit"* ]] -} - @test "shows write disabled message when enable_write_server is false" { run_session_with_config '{"enforce_mcp_tools": true, "enable_write_server": false}' assert_success diff --git a/plugin-tests/github-mcp/write_tools_review.bats b/plugin-tests/github-mcp/write_tools_review.bats index 424f386..fb93945 100644 --- a/plugin-tests/github-mcp/write_tools_review.bats +++ b/plugin-tests/github-mcp/write_tools_review.bats @@ -32,33 +32,43 @@ setup() { printf '%s\n' "${GH_STUB_HEAD_SHA:-0123456789abcdef0123456789abcdef01234567}" return 0 fi - # comment_edit's GraphQL calls, told apart by their query text. + # comment_edit's calls, told apart by what the argv names. Each kind has + # its own stdout, stderr, and exit code: GH_STUB__OUTPUT/_STDERR/_EXIT. + local kind="" get_re='^api repos/[^ ]+/comments/[0-9]+$' if [[ "$*" == *"reviews(states: [PENDING]"* ]]; then - printf '%s\n' "${GH_STUB_LOOKUP_OUTPUT}" - return "${GH_STUB_LOOKUP_EXIT}" + kind=LOOKUP + elif [[ "$*" == *"updatePullRequestReviewComment"* ]]; then + kind=MUTATION + elif [[ "$*" == *" -X PATCH "* || "$*" == *" -X PUT "* ]]; then + kind=WRITE + elif [[ "$*" =~ ${get_re} ]]; then + kind=GET fi - if [[ "$*" == *"updatePullRequestReviewComment"* ]]; then - printf '%s\n' "${GH_STUB_MUTATION_OUTPUT}" - return "${GH_STUB_MUTATION_EXIT}" - fi - if [[ -n "${GH_STUB_APPLY_JQ:-}" ]]; then - # Behave like real gh: run the --jq filter over the canned response. - local arg filter="" prev="" - for arg in "$@"; do - [[ "${prev}" == "--jq" ]] && filter="${arg}" - prev="${arg}" - done - printf '%s' "${GH_STUB_OUTPUT}" | jq -r "${filter}" - return $? + if [[ -n "${kind}" ]]; then + local out_var="GH_STUB_${kind}_OUTPUT" err_var="GH_STUB_${kind}_STDERR" exit_var="GH_STUB_${kind}_EXIT" + [[ -n "${!err_var:-}" ]] && printf '%s\n' "${!err_var}" >&2 + if [[ "${kind}" == WRITE && -n "${GH_STUB_APPLY_JQ:-}" ]]; then + # Behave like real gh: run the --jq filter over the canned response. + local arg filter="" prev="" + for arg in "$@"; do + [[ "${prev}" == "--jq" ]] && filter="${arg}" + prev="${arg}" + done + printf '%s' "${!out_var}" | jq -r "${filter}" + return $? + fi + [[ -n "${!out_var:-}" ]] && printf '%s\n' "${!out_var}" + return "${!exit_var:-0}" fi gh_stub_respond } reset_gh_stub - # Default: the caller has no pending review. - GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[]}}}}}' - GH_STUB_LOOKUP_EXIT=0 - GH_STUB_MUTATION_OUTPUT="" - GH_STUB_MUTATION_EXIT=0 + local kind + for kind in GET WRITE LOOKUP MUTATION; do + printf -v "GH_STUB_${kind}_OUTPUT" '%s' "" + printf -v "GH_STUB_${kind}_STDERR" '%s' "" + printf -v "GH_STUB_${kind}_EXIT" '%s' 0 + done GH_STUB_HEAD_SHA="0123456789abcdef0123456789abcdef01234567" } @@ -279,10 +289,30 @@ _assert_rejects_incomplete_comment() { assert_gh_args_contain "-f body=Addressed, thanks!" } + # ============================================================================ # comment_edit # ============================================================================ +NOT_FOUND_STDERR="gh: Not Found (HTTP 404)" +SERVER_ERROR_STDERR="gh: Internal Server Error (HTTP 500)" +WARNING_STDERR="warning: a new gh release is available" + +PENDING_NONE='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[]}}}}}' +PENDING_MATCH='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_other","fullDatabaseId":"4000"},{"id":"PRRC_node5551","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' +PENDING_BIG='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_near","fullDatabaseId":"4195291200"},{"id":"PRRC_big","fullDatabaseId":"4195291201"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' +PENDING_FULL_PAGE='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_a","fullDatabaseId":"1"}],"pageInfo":{"hasNextPage":true}}}]}}}}}' +MUTATION_OK='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":"https://github.com/shopware/shopware/pull/100#discussion_r5551"}}}}' + +# The GET answer for a comment that belongs to issue or PR N. +stub_get_issue() { + GH_STUB_GET_OUTPUT=$(jq -cn --arg u "https://api.github.com/repos/shopware/shopware/issues/$1" '{id: 1, issue_url: $u}') +} + +stub_get_pull() { + GH_STUB_GET_OUTPUT=$(jq -cn --arg u "https://api.github.com/repos/shopware/shopware/pulls/$1" '{id: 1, pull_request_url: $u}') +} + # gh must not have run: the rejection happens before any API call. assert_gh_not_called() { [[ ! -s "${GH_ARGS_FILE}" ]] || fail "Expected no gh call, got: $(cat "${GH_ARGS_FILE}")" @@ -302,82 +332,535 @@ assert_gh_last_args_equal() { [[ "${actual}" == "${expected}" ]] || fail "Expected last gh args '${expected}', got: '${actual}'" } -assert_no_rest_call() { - ! grep -q -- "-X " "${GH_ARGS_FILE}" || fail "Expected no REST call, got: $(cat "${GH_ARGS_FILE}")" +# The Nth gh invocation, compared whole. +assert_gh_call_equal() { + local n="$1" expected="$2" actual + actual=$(sed -n "${n}p" "${GH_ARGS_FILE}") + [[ "${actual}" == "${expected}" ]] || fail "Expected gh call ${n} to be '${expected}', got: '${actual}'" +} + +assert_gh_last_args_contain_all() { + local last needle + last=$(tail -n 1 "${GH_ARGS_FILE}") + for needle in "$@"; do + [[ "${last}" == *"${needle}"* ]] || fail "Expected last gh call to contain '${needle}', got: ${last}" + done +} + +# The Nth gh invocation contains every given text. +assert_gh_call_contains_all() { + local n="$1" call needle + shift + call=$(sed -n "${n}p" "${GH_ARGS_FILE}") + for needle in "$@"; do + [[ "${call}" == *"${needle}"* ]] || fail "Expected gh call ${n} to contain '${needle}', got: ${call}" + done +} + +# Exactly N gh calls ran. +assert_gh_call_count() { + local expected="$1" actual + actual=$(wc -l < "${GH_ARGS_FILE}" | tr -d ' ') + [[ "${actual}" -eq "${expected}" ]] || fail "Expected ${expected} gh call(s), got ${actual}: $(cat "${GH_ARGS_FILE}")" +} + +# No PATCH or PUT was sent. +assert_no_rest_write() { + ! grep -q -- " -X " "${GH_ARGS_FILE}" || fail "Expected no REST write, got: $(cat "${GH_ARGS_FILE}")" } assert_no_graphql_call() { ! grep -q -- "graphql" "${GH_ARGS_FILE}" || fail "Expected no GraphQL call, got: $(cat "${GH_ARGS_FILE}")" } -@test "comment_edit patches an issue comment from an issues URL without a host prefix" { - GH_STUB_OUTPUT="https://github.com/shopware/shopware/issues/7#issuecomment-9001" +# A failed call reports through an error message that starts with "Error:". +assert_error_output() { + assert_output --regexp '^Error:' +} + +# ---- issue and PR conversation comments ------------------------------------ + +@test "comment_edit reads an issue comment, then patches it when it belongs to the issue in the URL" { + stub_get_issue 7 + GH_STUB_WRITE_OUTPUT="https://github.com/shopware/shopware/issues/7#issuecomment-9001" run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "New text"}' assert_success assert_output "https://github.com/shopware/shopware/issues/7#issuecomment-9001" - assert_gh_args_equal "api repos/shopware/shopware/issues/comments/9001 -X PATCH -f body=New text --jq .html_url // empty" + assert_gh_call_count 2 + assert_gh_call_equal 1 "api repos/shopware/shopware/issues/comments/9001" + assert_gh_call_equal 2 "api repos/shopware/shopware/issues/comments/9001 -X PATCH -f body=New text --jq .html_url // empty" } @test "comment_edit patches a PR conversation comment through the issues comments endpoint" { - GH_STUB_OUTPUT="https://example/c" + stub_get_issue 100 + GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#issuecomment-9002", "body": "New text"}' assert_success - assert_gh_args_equal "api repos/shopware/shopware/issues/comments/9002 -X PATCH -f body=New text --jq .html_url // empty" + assert_gh_call_equal 1 "api repos/shopware/shopware/issues/comments/9002" + assert_gh_call_equal 2 "api repos/shopware/shopware/issues/comments/9002 -X PATCH -f body=New text --jq .html_url // empty" +} + +@test "comment_edit refuses to edit a conversation comment that belongs to another issue or PR" { + stub_get_issue 8 + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "#8" + assert_output --partial "#7" + assert_gh_call_count 1 + assert_no_rest_write +} + +@test "comment_edit does not treat an issue number that merely ends in the URL's number as a match" { + stub_get_issue 17 + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + assert_failure + assert_error_output + assert_no_rest_write } -@test "comment_edit patches an inline review comment from a discussion_r anchor" { - GH_STUB_OUTPUT="https://example/c" +@test "comment_edit refuses an issue comment whose issue_url ends in a trailing slash, with no write" { + GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/shopware/shopware/issues/7/"}' + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + assert_failure + assert_error_output + assert_gh_call_count 1 + assert_no_rest_write +} + +@test "comment_edit refuses an inline comment whose pull_request_url ends in a trailing slash, with no write" { + GH_STUB_GET_OUTPUT='{"id":1,"pull_request_url":"https://api.github.com/repos/shopware/shopware/pulls/100/"}' + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_gh_call_count 1 + assert_no_rest_write +} + +@test "comment_edit fails with gh's message when the conversation comment does not exist, and does not patch" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "Not Found (HTTP 404)" + assert_gh_call_count 1 + assert_no_rest_write + assert_no_graphql_call +} + +@test "comment_edit fails when the read of a conversation comment names no issue, and does not patch" { + GH_STUB_GET_OUTPUT='{"id":9001}' + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + assert_failure + assert_error_output + assert_gh_call_count 1 + assert_no_rest_write +} + +# ---- inline review comments: submitted --------------------------------------- + +@test "comment_edit patches an inline review comment from a discussion_r anchor with no GraphQL call" { + stub_get_pull 100 + GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "New text"}' assert_success - assert_gh_last_args_equal "api repos/shopware/shopware/pulls/comments/5551 -X PATCH -f body=New text --jq .html_url // empty" + assert_output "https://example/c" + assert_gh_call_count 2 + assert_gh_call_equal 1 "api repos/shopware/shopware/pulls/comments/5551" + assert_gh_call_equal 2 "api repos/shopware/shopware/pulls/comments/5551 -X PATCH -f body=New text --jq .html_url // empty" + assert_no_graphql_call } @test "comment_edit patches an inline review comment from an r anchor on /files" { - GH_STUB_OUTPUT="https://example/c" + stub_get_pull 100 + GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "shopware/shopware/pull/100/files#r5552", "body": "New text"}' assert_success assert_gh_last_args_equal "api repos/shopware/shopware/pulls/comments/5552 -X PATCH -f body=New text --jq .html_url // empty" + assert_no_graphql_call } @test "comment_edit patches an inline review comment from an r anchor on /changes" { - GH_STUB_OUTPUT="https://example/c" + stub_get_pull 100 + GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "shopware/shopware/pull/100/changes#r5553", "body": "New text"}' assert_success assert_gh_last_args_equal "api repos/shopware/shopware/pulls/comments/5553 -X PATCH -f body=New text --jq .html_url // empty" + assert_no_graphql_call +} + +@test "comment_edit refuses to edit an inline comment that belongs to another PR, with no write and no lookup" { + stub_get_pull 101 + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "#101" + assert_output --partial "#100" + assert_gh_call_count 1 + assert_no_rest_write + assert_no_graphql_call +} + +@test "comment_edit does not fall back to the pending-review lookup when the PATCH of a submitted comment fails" { + stub_get_pull 100 + GH_STUB_WRITE_EXIT=1 + GH_STUB_WRITE_STDERR="${NOT_FOUND_STDERR}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "PATCH repos/shopware/shopware/pulls/comments/5551 failed" + assert_gh_call_count 2 + assert_no_graphql_call +} + +@test "comment_edit fails without a lookup or write when reading the inline comment fails with a server error" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${SERVER_ERROR_STDERR}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "Internal Server Error (HTTP 500)" + assert_gh_call_count 1 + assert_no_rest_write + assert_no_graphql_call +} + +# ---- inline review comments: pending review --------------------------------- + +@test "comment_edit finds a comment in the pending review after a 404 and edits it with the mutation" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_OUTPUT="${MUTATION_OK}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "New text"}' + assert_success + assert_output "https://github.com/shopware/shopware/pull/100#discussion_r5551" + assert_gh_call_count 3 + assert_gh_call_equal 1 "api repos/shopware/shopware/pulls/comments/5551" + assert_gh_call_contains_all 2 "-f owner=shopware -f name=shopware -F number=100" + assert_gh_last_args_contain_all "updatePullRequestReviewComment" "-f id=PRRC_node5551" "-f body=New text" + assert_no_rest_write +} + +@test "comment_edit matches a pending-review comment on a database id above 32 bits, given as a string" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_BIG}" + GH_STUB_MUTATION_OUTPUT="${MUTATION_OK}" + run tool_comment_edit '{"url": "shopware/shopware/pull/100/files#r4195291201", "body": "x"}' + assert_success + assert_gh_call_count 3 + assert_gh_last_args_contain_all "-f id=PRRC_big" + assert_no_rest_write +} + +@test "comment_edit asks GitHub for fullDatabaseId, the field that holds an id above 32 bits" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_NONE}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_gh_call_contains_all 2 "nodes { id fullDatabaseId }" +} + +@test "comment_edit fails when the 404 comment is not in the pending review, with no mutation or write" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r9999", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "not a submitted review comment" + assert_output --partial "pending review on PR 100" + assert_gh_call_count 2 + assert_no_rest_write +} + +@test "comment_edit fails when the 404 comment is not found and the caller has no pending review" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_NONE}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_gh_call_count 2 + assert_no_rest_write +} + +@test "comment_edit fails when the 404 comment is not on the first page of a large pending review" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_FULL_PAGE}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "more than 100 comments" + assert_gh_call_count 2 + assert_no_rest_write +} + +@test "comment_edit edits a matching pending-review comment even when the review has further pages" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_node5551","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":true}}}]}}}}}' + GH_STUB_MUTATION_OUTPUT="${MUTATION_OK}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_success + assert_gh_call_count 3 +} + +@test "comment_edit ignores a match in another author's pending review when the caller's own review lacks it" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":false,"comments":{"nodes":[{"id":"PRRC_theirs","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false}}},{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_mine","fullDatabaseId":"4000"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "not in your pending review" + assert_gh_call_count 2 + assert_no_rest_write +} + +@test "comment_edit edits with the node id from the caller's review when it is not the first pending review" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":false,"comments":{"nodes":[{"id":"PRRC_theirs","fullDatabaseId":"4000"}],"pageInfo":{"hasNextPage":false}}},{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_mine","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' + GH_STUB_MUTATION_OUTPUT="${MUTATION_OK}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_success + assert_gh_call_count 3 + assert_gh_last_args_contain_all "-f id=PRRC_mine" +} + +@test "comment_edit fails when no pending review belongs to the caller, even if another's holds the comment" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":false,"comments":{"nodes":[{"id":"PRRC_theirs","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "not in your pending review" + assert_gh_call_count 2 + assert_no_rest_write +} + +@test "comment_edit fails when the caller's review is not among more than 100 pending reviews" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":true},"nodes":[{"viewerDidAuthor":false,"comments":{"nodes":[],"pageInfo":{"hasNextPage":false}}}]}}}}}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "more than 100 pending reviews" + assert_gh_call_count 2 + assert_no_rest_write +} + +@test "comment_edit asks GitHub for up to 100 pending reviews with their viewerDidAuthor flag" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_NONE}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_gh_call_contains_all 2 "reviews(states: [PENDING], first: 100) { pageInfo { hasNextPage } nodes { viewerDidAuthor comments" +} + +@test "comment_edit fails when the pending-review lookup exits non-zero, with gh's message and no mutation" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_EXIT=1 + GH_STUB_LOOKUP_STDERR="gh: Could not resolve to a Repository" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "pending-review lookup failed" + assert_output --partial "Could not resolve to a Repository" + assert_gh_call_count 2 + assert_no_rest_write +} + +@test "comment_edit fails when the lookup response carries GraphQL errors" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT='{"errors":[{"message":"Something broke"}],"data":null}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "Something broke" + assert_gh_call_count 2 +} + +@test "comment_edit fails when the lookup succeeds with no output" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "pending-review lookup failed: GitHub returned no output" + assert_gh_call_count 2 +} + +@test "comment_edit names the failed call and keeps gh's exit status when the lookup fails with no output" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_EXIT=5 + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure 5 + assert_error_output + assert_output --partial "gh api graphql failed with exit 5 and no output" +} + +@test "comment_edit fails when the lookup output is not JSON" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="bad gateway" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "unparseable response" + assert_gh_call_count 2 +} + +@test "comment_edit fails when the lookup response has no pull request" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":null}}}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "no pull request data" + assert_gh_call_count 2 +} + +@test "comment_edit fails with gh's message when the mutation exits non-zero, with no REST write" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_EXIT=1 + GH_STUB_MUTATION_STDERR="gh: Resource not accessible" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "Resource not accessible" + assert_gh_call_count 3 + assert_no_rest_write +} + +@test "comment_edit fails when the mutation response carries errors" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_OUTPUT='{"errors":[{"message":"Body cannot be blank"}]}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "Body cannot be blank" + assert_no_rest_write } -@test "comment_edit puts a review body with PUT on the PR's reviews endpoint" { - GH_STUB_OUTPUT="https://example/c" +@test "comment_edit fails when the mutation succeeds with no output" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_OUTPUT="" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "pending-review comment edit failed" +} + +@test "comment_edit fails when the mutation response has a null url" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":null}}}}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "no URL for the edited comment" +} + +@test "comment_edit names the failed call and keeps gh's exit status when the mutation fails with no output" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_EXIT=4 + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure 4 + assert_error_output + assert_output --partial "gh api graphql failed with exit 4 and no output" +} + +# ---- review summary body ------------------------------------------------------ + +@test "comment_edit puts a review body with PUT on the PR's reviews endpoint without reading it first" { + GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-777", "body": "New summary"}' assert_success assert_gh_args_equal "api repos/shopware/shopware/pulls/100/reviews/777 -X PUT -f body=New summary --jq .html_url // empty" } +# ---- stderr warnings on successful calls ------------------------------------- + +@test "comment_edit returns the clean URL when a successful read and PATCH each print a warning on stderr" { + stub_get_issue 7 + GH_STUB_GET_STDERR="${WARNING_STDERR}" + GH_STUB_WRITE_STDERR="${WARNING_STDERR}" + GH_STUB_WRITE_OUTPUT="https://github.com/shopware/shopware/issues/7#issuecomment-9001" + run --separate-stderr tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + assert_success + assert_output "https://github.com/shopware/shopware/issues/7#issuecomment-9001" +} + +@test "comment_edit returns the clean URL when a successful review PUT prints a warning on stderr" { + GH_STUB_WRITE_STDERR="${WARNING_STDERR}" + GH_STUB_WRITE_OUTPUT="https://example/review" + run --separate-stderr tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-777", "body": "x"}' + assert_success + assert_output "https://example/review" +} + +@test "comment_edit returns the clean URL when a successful lookup and mutation each print a warning on stderr" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_LOOKUP_STDERR="${WARNING_STDERR}" + GH_STUB_MUTATION_OUTPUT="${MUTATION_OK}" + GH_STUB_MUTATION_STDERR="${WARNING_STDERR}" + run --separate-stderr tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_success + assert_output "https://github.com/shopware/shopware/pull/100#discussion_r5551" +} + +# ---- URL handling ----------------------------------------------------------------- + @test "comment_edit takes the repository from the URL, not from the default repo" { - GH_STUB_OUTPUT="https://example/c" + GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/other-org/other.repo/issues/7"}' + GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "other-org/other.repo/issues/7#issuecomment-1", "body": "x"}' assert_success - assert_gh_args_equal "api repos/other-org/other.repo/issues/comments/1 -X PATCH -f body=x --jq .html_url // empty" + assert_gh_call_equal 1 "api repos/other-org/other.repo/issues/comments/1" + assert_gh_call_equal 2 "api repos/other-org/other.repo/issues/comments/1 -X PATCH -f body=x --jq .html_url // empty" } @test "comment_edit strips the http and www.github.com prefixes" { - GH_STUB_OUTPUT="https://example/c" + stub_get_issue 7 + GH_STUB_WRITE_OUTPUT="https://example/c" local prefix for prefix in "http://github.com/" "https://www.github.com/"; do : > "${GH_ARGS_FILE}" run tool_comment_edit "{\"url\": \"${prefix}shopware/shopware/issues/7#issuecomment-3\", \"body\": \"x\"}" assert_success - assert_gh_args_equal "api repos/shopware/shopware/issues/comments/3 -X PATCH -f body=x --jq .html_url // empty" + assert_gh_last_args_equal "api repos/shopware/shopware/issues/comments/3 -X PATCH -f body=x --jq .html_url // empty" done } -@test "comment_edit passes a body with special characters as one -f argument" { - GH_STUB_OUTPUT="https://example/c" - run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "a=b \"quoted\" $(x)"}' - assert_success - assert_gh_args_equal 'api repos/shopware/shopware/issues/comments/3 -X PATCH -f body=a=b "quoted" $(x) --jq .html_url // empty' -} - @test "comment_edit requires url" { run tool_comment_edit '{"body": "x"}' assert_failure @@ -395,6 +878,7 @@ assert_no_graphql_call() { @test "comment_edit rejects a URL on another host" { run tool_comment_edit '{"url": "https://ghe.example.com/shopware/shopware/pull/1#issuecomment-5", "body": "x"}' assert_failure + assert_error_output assert_output --partial "must be a github.com URL" assert_gh_not_called } @@ -402,6 +886,7 @@ assert_no_graphql_call() { @test "comment_edit rejects a bare host that is not github.com" { run tool_comment_edit '{"url": "ghe.example.com/shopware/pull/1#issuecomment-5", "body": "x"}' assert_failure + assert_error_output assert_output --partial "url must be" assert_gh_not_called } @@ -504,47 +989,58 @@ assert_no_graphql_call() { assert_gh_not_called } -@test "comment_edit surfaces the gh error and fails when the request fails" { - GH_STUB_EXIT=1 - GH_STUB_OUTPUT='{"message":"Not Found","status":"404"}' - run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' +# ---- the edit request's result ------------------------------------------------ + +@test "comment_edit fails with the call named and gh's message when the PATCH fails" { + stub_get_issue 7 + GH_STUB_WRITE_EXIT=1 + GH_STUB_WRITE_OUTPUT='{"message":"Validation Failed"}' + GH_STUB_WRITE_STDERR="gh: Validation Failed (HTTP 422)" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-5", "body": "x"}' assert_failure - assert_output --partial "Not Found" + assert_error_output + assert_output --partial "PATCH repos/shopware/shopware/issues/comments/5 failed: gh: Validation Failed (HTTP 422)" +} + +@test "comment_edit names the failed call and keeps gh's exit status when the PATCH fails with no output" { + stub_get_issue 7 + GH_STUB_WRITE_EXIT=3 + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' + assert_failure 3 + assert_error_output + assert_output --partial "PATCH repos/shopware/shopware/issues/comments/3 failed: gh exited with status 3 and no output" } -@test "comment_edit fails with a message when the REST call succeeds with no output" { - GH_STUB_OUTPUT="" +@test "comment_edit fails with a message when the PATCH succeeds with no output" { + stub_get_issue 7 + GH_STUB_WRITE_OUTPUT="" run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' assert_failure + assert_error_output assert_output --partial "no URL for the edited comment" } -@test "comment_edit fails when the REST response has no html_url field" { +@test "comment_edit fails when the PATCH response has no html_url field" { + stub_get_issue 7 GH_STUB_APPLY_JQ=1 - GH_STUB_OUTPUT='{"id":3,"body":"x"}' + GH_STUB_WRITE_OUTPUT='{"id":3,"body":"x"}' run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' assert_failure + assert_error_output assert_output --partial "no URL for the edited comment" refute_output --partial "null" } -@test "comment_edit returns html_url from the REST response when the stub applies the filter" { +@test "comment_edit returns html_url from the PATCH response when the stub applies the filter" { + stub_get_issue 7 GH_STUB_APPLY_JQ=1 - GH_STUB_OUTPUT='{"id":3,"html_url":"https://example/c"}' + GH_STUB_WRITE_OUTPUT='{"id":3,"html_url":"https://example/c"}' run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' assert_success assert_output "https://example/c" } -@test "comment_edit names the failed call when the REST call fails with no output" { - GH_STUB_EXIT=3 - GH_STUB_OUTPUT="" - run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' - assert_failure 3 - assert_output --partial "gh api repos/shopware/shopware/issues/comments/3 failed with exit 3 and no output" -} - -PENDING_MATCH='{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"comments":{"nodes":[{"id":"PRRC_other","databaseId":4000},{"id":"PRRC_node5551","databaseId":5551}],"pageInfo":{"hasNextPage":false}}}]}}}}}' +# ---- argv integrity --------------------------------------------------------------- # Every argument of the last gh call, one array element each. read_gh_argv() { @@ -568,8 +1064,19 @@ assert_one_argv_equal() { TRICKY_BODY=$'-leading dash, two words a=b @file.txt "double" \'single\'\nsecond line' +@test "comment_edit passes a body with special characters as one -f argument" { + stub_get_issue 7 + GH_STUB_WRITE_OUTPUT="https://example/c" + # shellcheck disable=SC2016 # the body's $(x) must stay literal + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "a=b \"quoted\" $(x)"}' + assert_success + # shellcheck disable=SC2016 + assert_gh_last_args_equal 'api repos/shopware/shopware/issues/comments/3 -X PATCH -f body=a=b "quoted" $(x) --jq .html_url // empty' +} + @test "comment_edit passes a body with a newline, spaces, =, @, quotes, and a leading dash as exactly one REST argument" { - GH_STUB_OUTPUT="https://example/c" + stub_get_issue 7 + GH_STUB_WRITE_OUTPUT="https://example/c" local json json=$(jq -cn --arg b "${TRICKY_BODY}" '{url: "shopware/shopware/issues/7#issuecomment-3", body: $b}') run tool_comment_edit "${json}" @@ -579,166 +1086,15 @@ TRICKY_BODY=$'-leading dash, two words a=b @file.txt "double" \'single\'\nsecond } @test "comment_edit passes the same body as exactly one argument to the GraphQL mutation" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" - GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":"https://example/u"}}}}' + GH_STUB_MUTATION_OUTPUT="${MUTATION_OK}" local json json=$(jq -cn --arg b "${TRICKY_BODY}" '{url: "https://github.com/shopware/shopware/pull/100#discussion_r5551", body: $b}') run tool_comment_edit "${json}" assert_success - assert_no_rest_call + assert_no_rest_write read_gh_argv assert_one_argv_equal "body=${TRICKY_BODY}" } - -@test "comment_edit edits a comment in the pending review with the mutation and no REST call" { - GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" - GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":"https://github.com/shopware/shopware/pull/100#discussion_r5551"}}}}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "New text"}' - assert_success - assert_output "https://github.com/shopware/shopware/pull/100#discussion_r5551" - assert_gh_args_contain "-f owner=shopware -f name=shopware -F number=100" - assert_gh_last_args_contain_all "updatePullRequestReviewComment" "-f id=PRRC_node5551" "-f body=New text" - assert_no_rest_call -} - -@test "comment_edit looks up the pending review for an r anchor on /files" { - GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" - GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":"https://example/u"}}}}' - run tool_comment_edit '{"url": "shopware/shopware/pull/100/files#r5551", "body": "x"}' - assert_success - assert_output "https://example/u" - assert_no_rest_call -} - -@test "comment_edit fails when the GraphQL mutation succeeds with no output" { - GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" - GH_STUB_MUTATION_OUTPUT="" - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_output --partial "no URL for the edited comment" -} - -@test "comment_edit fails when the GraphQL mutation response has a null url" { - GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" - GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":null}}}}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_output --partial "no URL for the edited comment" -} - -@test "comment_edit names the failed call when the GraphQL mutation fails with no output" { - GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" - GH_STUB_MUTATION_OUTPUT="" - GH_STUB_MUTATION_EXIT=4 - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure 4 - assert_output --partial "gh api graphql failed with exit 4 and no output" -} - -@test "comment_edit fails when the pending-review lookup succeeds with no output" { - GH_STUB_LOOKUP_OUTPUT="" - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_output --partial "pending-review lookup failed: GitHub returned no output" - assert_no_rest_call -} - -@test "comment_edit names the failed call when the pending-review lookup fails with no output" { - GH_STUB_LOOKUP_OUTPUT="" - GH_STUB_LOOKUP_EXIT=5 - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure 5 - assert_output --partial "gh api graphql failed with exit 5 and no output" - assert_no_rest_call -} - -assert_gh_last_args_contain_all() { - local last needle - last=$(tail -n 1 "${GH_ARGS_FILE}") - for needle in "$@"; do - [[ "${last}" == *"${needle}"* ]] || fail "Expected last gh call to contain '${needle}', got: ${last}" - done -} - -@test "comment_edit falls through to REST when the ID is not in the pending review" { - GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" - GH_STUB_OUTPUT="https://github.com/shopware/shopware/pull/100#discussion_r9999" - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r9999", "body": "x"}' - assert_success - assert_output "https://github.com/shopware/shopware/pull/100#discussion_r9999" - assert_gh_last_args_equal "api repos/shopware/shopware/pulls/comments/9999 -X PATCH -f body=x --jq .html_url // empty" - ! grep -q "updatePullRequestReviewComment" "${GH_ARGS_FILE}" -} - -@test "comment_edit falls through to REST when the caller has no pending review" { - GH_STUB_OUTPUT="https://github.com/shopware/shopware/pull/100#discussion_r5551" - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_success - assert_gh_last_args_equal "api repos/shopware/shopware/pulls/comments/5551 -X PATCH -f body=x --jq .html_url // empty" -} - -@test "comment_edit errors without any further call when the pending-review lookup fails" { - GH_STUB_LOOKUP_EXIT=1 - GH_STUB_LOOKUP_OUTPUT="gh: Could not resolve to a Repository" - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_output --partial "pending-review lookup failed" - assert_output --partial "Could not resolve to a Repository" - [[ $(wc -l < "${GH_ARGS_FILE}") -eq 1 ]] || fail "Expected exactly the lookup call, got: $(cat "${GH_ARGS_FILE}")" -} - -@test "comment_edit errors when the lookup response carries GraphQL errors" { - GH_STUB_LOOKUP_OUTPUT='{"errors":[{"message":"Something broke"}],"data":null}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_output --partial "pending-review lookup failed" - assert_output --partial "Something broke" - [[ $(wc -l < "${GH_ARGS_FILE}") -eq 1 ]] || fail "Expected exactly the lookup call, got: $(cat "${GH_ARGS_FILE}")" -} - -@test "comment_edit errors without a REST call when the mutation exits non-zero" { - GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" - GH_STUB_MUTATION_EXIT=1 - GH_STUB_MUTATION_OUTPUT="gh: Resource not accessible" - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_output --partial "Resource not accessible" - assert_no_rest_call -} - -@test "comment_edit errors without a REST call when the mutation response carries errors" { - GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" - GH_STUB_MUTATION_OUTPUT='{"errors":[{"message":"Body cannot be blank"}]}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_output --partial "Body cannot be blank" - assert_no_rest_call -} - -@test "comment_edit errors without a REST call when the pending review has more comments than the lookup page and no match" { - GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"comments":{"nodes":[{"id":"PRRC_a","databaseId":1}],"pageInfo":{"hasNextPage":true}}}]}}}}}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_output --partial "more than 100 comments" - assert_no_rest_call -} - -@test "comment_edit edits a matching comment even when the pending review has further pages" { - GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"comments":{"nodes":[{"id":"PRRC_node5551","databaseId":5551}],"pageInfo":{"hasNextPage":true}}}]}}}}}' - GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":"https://example/u"}}}}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_success - assert_output "https://example/u" -} - -@test "comment_edit makes no GraphQL call for issue-comment and review-summary anchors" { - GH_STUB_OUTPUT="https://example/c" - local url - for url in "shopware/shopware/issues/7#issuecomment-3" "shopware/shopware/pull/100#issuecomment-3" "shopware/shopware/pull/100#pullrequestreview-777"; do - : > "${GH_ARGS_FILE}" - run tool_comment_edit "{\"url\": \"${url}\", \"body\": \"x\"}" - assert_success - assert_no_graphql_call - [[ $(wc -l < "${GH_ARGS_FILE}") -eq 1 ]] || fail "Expected exactly one REST call for ${url}, got: $(cat "${GH_ARGS_FILE}")" - done -} diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index 4d71f23..531f210 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -14,17 +14,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `pull/N#discussion_rID` and `pull/N/files#rID` or `pull/N/changes#rID` (inline review comments), and `pull/N#pullrequestreview-ID` (a review's summary body). The URL may carry a leading `https://github.com/`; a URL on another host, without an anchor, with an unknown anchor, or with a - non-numeric ID or number is rejected before any `gh` call. The two inline review comment forms - also edit a comment in the caller's own unsubmitted (pending) review, which the REST endpoint - rejects with 404: the tool looks up the pending review through GraphQL and edits a matching - comment with the `updatePullRequestReviewComment` mutation. Every failure is returned as an - error and never falls back to another route. It returns the updated comment's `html_url`, or the - `url` for a comment in a pending review. + non-numeric ID or number is rejected before any `gh` call. A conversation or inline comment is read + first and edited only when it belongs to the issue or PR `N` in the URL; a comment of another + issue or PR is an error and nothing is edited. When the read of an inline comment answers 404, the + comment may sit in the caller's own unsubmitted (pending) review: the tool looks the review up + through GraphQL, matching `fullDatabaseId`, and edits a matching comment with the + `updatePullRequestReviewComment` mutation. No match is an error. Every failure is returned as an + error that starts with `Error:`, and a warning that `gh` writes to stderr on a successful call does + not fail the edit. It returns the updated comment's `html_url`, or the `url` for a comment in a + pending review. - `check-api-tools.sh` redirects `PATCH repos/O/R/issues/comments/ID`, `PATCH repos/O/R/pulls/comments/ID`, and `PUT repos/O/R/pulls/N/reviews/ID` on the `api` write tool - to `comment_edit`, under `block_api_tool_write`. The block messages for `gh pr comment` and - `gh issue comment` now also name `comment_edit` for editing an existing comment, which covers - `--edit-last`. + to `comment_edit`, under `block_api_tool_write`. The message for a conversation comment points to + `issue_view` or `pr_view` with `fields=comments` for the comment's URL. The block messages for + `gh pr comment` and `gh issue comment` now also name `comment_edit` for editing an existing + comment, which covers `--edit-last`. ## [5.0.1] - 2026-10-03 diff --git a/plugins/github-mcp/REFERENCE.md b/plugins/github-mcp/REFERENCE.md index 0d39ac9..a7950a4 100644 --- a/plugins/github-mcp/REFERENCE.md +++ b/plugins/github-mcp/REFERENCE.md @@ -735,7 +735,7 @@ Use gh-tooling-write pr_review_reply with number 14642 and comment_id 1234567 an #### `comment_edit` -Replace the body of one existing comment, whatever its kind: an issue or PR conversation comment, an inline review comment, or a review's summary body. The kind comes from the anchor of `url`, so pass the URL exactly as `issue_comment` or `pr_comment` returned it, or as the `html_url` from `pr_comments`, `pr_reviews`, or `pr_review_reply`. The body replaces the whole comment text; send the full new text, not a diff. Returns the updated comment's `html_url`. +Replace the body of one existing comment, whatever its kind: an issue or PR conversation comment, an inline review comment, or a review's summary body. The kind comes from the anchor of `url`, so pass the URL exactly as `issue_comment` or `pr_comment` returned it, as the `url` of a comment from `issue_view` or `pr_view` with `fields=comments`, or as the `html_url` from `pr_comments`, `pr_reviews`, or `pr_review_reply`. The body replaces the whole comment text; send the full new text, not a diff. Returns the updated comment's URL. ``` Use gh-tooling-write comment_edit with url "https://github.com/shopware/shopware/pull/14642#issuecomment-2001" and body "CI is green, ready to merge" @@ -752,7 +752,11 @@ Accepted `url` forms, each with or without a leading `https://github.com/` (`htt | `OWNER/REPO/pull/N/files#rID` or `.../changes#rID` | Inline review comment | `PATCH repos/OWNER/REPO/pulls/comments/ID` (see below) | | `OWNER/REPO/pull/N#pullrequestreview-ID` | Review summary body | `PUT repos/OWNER/REPO/pulls/N/reviews/ID` | -The two inline review comment forms also cover a comment in your own unsubmitted (pending) review, which the REST endpoint answers with 404. The tool first looks up your pending review on the PR through GraphQL. If the comment is in it, the tool edits it with the `updatePullRequestReviewComment` mutation and returns the comment's `url`. If you have no pending review, or the comment is not in it, the tool sends the `PATCH` shown. Every failure is returned as an error: a failed lookup, a failed mutation, or a pending review of more than 100 comments in which the comment is not found. None of these falls back to the `PATCH`. +Before editing a conversation comment or an inline review comment, the tool reads it (`GET`) and checks that it belongs to the issue or PR `N` in the URL: the comment's `issue_url` must end in `/issues/N` (PR conversation comments also use `/issues/N`), or its `pull_request_url` in `/pulls/N`. A comment of another issue or PR is an error naming both numbers, and nothing is edited. A failed read, including a 404 on a conversation comment, is an error with gh's message and no edit. + +For the two inline review comment forms, a 404 on that read means the comment is not a submitted one. It can still be in your own unsubmitted (pending) review, which the REST endpoint does not return. The tool then looks up your pending review on PR `N` through GraphQL (among up to 100 pending reviews, only the one you wrote), matching the comment's `fullDatabaseId`, and edits a match with the `updatePullRequestReviewComment` mutation. It returns the comment's `url`. No match is an error: the comment is not a submitted review comment on the repository and not in your pending review on PR `N`. A pending review of more than 100 comments in which the comment is not found is an error too. So is a PR with more than 100 pending reviews none of which is yours. A read that fails with anything other than a 404, a failed lookup, and a failed mutation are each an error, and none makes a further call. The review summary form sends its `PUT` without a read. + +Every failure is an error: the output starts with `Error:` and the call fails. Output that a successful `gh` call writes to stderr, such as a warning, does not fail the edit. A failed `gh` call returns its stderr message in the error. Any other URL fails before the request: no anchor, an unknown anchor, a non-numeric number or ID, `#discussion_rID`, `#rID`, or `#pullrequestreview-ID` on an `issues/N` URL, `#rID` without `/files` or `/changes`, and extra path segments or a query string. The repository comes from the URL, so the tool takes no `repo` parameter. diff --git a/plugins/github-mcp/hooks/scripts/check-api-tools.sh b/plugins/github-mcp/hooks/scripts/check-api-tools.sh index 0c4fac7..73cee86 100755 --- a/plugins/github-mcp/hooks/scripts/check-api-tools.sh +++ b/plugins/github-mcp/hooks/scripts/check-api-tools.sh @@ -168,8 +168,12 @@ if [[ "$IS_WRITE" == "true" ]]; then # Edit an existing comment: issue/PR conversation comment, inline review comment, # review body. The anchored comment URL is the tool's input. - if [[ "$METHOD" == "PATCH" ]] && echo "$ENDPOINT_PATH" | grep -qE '(^|/)repos/[^/]+/[^/]+/(issues|pulls)/comments/[0-9]+/?$'; then - block_tool "comment_edit" "Use comment_edit with the comment's url (the one issue_comment or pr_comment returned, or the html_url from pr_comments or pr_reviews) and the new body." + if [[ "$METHOD" == "PATCH" ]] && echo "$ENDPOINT_PATH" | grep -qE '(^|/)repos/[^/]+/[^/]+/issues/comments/[0-9]+/?$'; then + block_tool "comment_edit" "Use comment_edit with the comment's url and the new body. Take the url from the comment's own url field in issue_view or pr_view with fields=comments, or from the one issue_comment or pr_comment returned." + fi + + if [[ "$METHOD" == "PATCH" ]] && echo "$ENDPOINT_PATH" | grep -qE '(^|/)repos/[^/]+/[^/]+/pulls/comments/[0-9]+/?$'; then + block_tool "comment_edit" "Use comment_edit with the inline comment's url (the html_url from pr_comments, or the one pr_review_reply returned) and the new body." fi if [[ "$METHOD" == "PUT" ]] && echo "$ENDPOINT_PATH" | grep -qE '(^|/)repos/[^/]+/[^/]+/pulls/[0-9]+/reviews/[0-9]+/?$'; then diff --git a/plugins/github-mcp/mcp-server-gh/lib/review_write.sh b/plugins/github-mcp/mcp-server-gh/lib/review_write.sh index 7593145..e45bae4 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/review_write.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/review_write.sh @@ -231,22 +231,59 @@ tool_pr_review_reply() { echo "${__raw}" } +####################################### +# Run one gh api graphql call for comment_edit and classify the result. A +# non-zero exit, an empty stdout, an `errors` field, or stdout that is not +# JSON each count as a failure. stdout alone is parsed; stderr only explains a +# failed call, so a warning on a successful call never fails it. +# Arguments: +# $1 name of the caller's variable that receives the result, +# $2 label for the error message (for example "pending-review lookup"), +# $3... the gh command and its arguments. +# Outputs: +# Nothing on stdout; the result goes to the variable named by $1. +# Returns: +# 0 with the response JSON in the variable. Otherwise gh's exit status (1 +# when gh exited 0) with an "Error: ..." message in the variable. +####################################### +_comment_edit_graphql() { + local __ceg_var="$1" __ceg_label="$2" + shift 2 + local __ceg_out __ceg_err __ceg_exit=0 __ceg_problem="" + + _gh_capture_split __ceg_out __ceg_err "$@" || __ceg_exit=$? + if [[ ${__ceg_exit} -ne 0 ]]; then + __ceg_problem="${__ceg_err:-${__ceg_out:-gh api graphql failed with exit ${__ceg_exit} and no output}}" + elif [[ -z "${__ceg_out}" ]]; then + __ceg_problem="GitHub returned no output" + elif ! __ceg_problem=$(printf '%s' "${__ceg_out}" | jq -r 'if (.errors // empty) then "GraphQL errors: \(.errors | tostring)" else empty end' 2>/dev/null); then + __ceg_problem="unparseable response: ${__ceg_out}" + fi + + if [[ -n "${__ceg_problem}" ]]; then + [[ ${__ceg_exit} -ne 0 ]] || __ceg_exit=1 + printf -v "${__ceg_var}" '%s' "Error: comment_edit: ${__ceg_label} failed: ${__ceg_problem}" + return "${__ceg_exit}" + fi + printf -v "${__ceg_var}" '%s' "${__ceg_out}" +} + +####################################### # Replace the body of one existing comment, whatever its kind. The kind comes -# from the URL anchor, and a URL that matches none of the forms below fails -# before any gh call: -# OWNER/REPO/issues/N#issuecomment-ID → PATCH repos/O/R/issues/comments/ID -# OWNER/REPO/pull/N#issuecomment-ID → PATCH repos/O/R/issues/comments/ID -# OWNER/REPO/pull/N#discussion_rID → PATCH repos/O/R/pulls/comments/ID -# OWNER/REPO/pull/N/files|changes#rID → PATCH repos/O/R/pulls/comments/ID -# OWNER/REPO/pull/N#pullrequestreview-ID → PUT repos/O/R/pulls/N/reviews/ID -# For the two review-comment forms, the caller's own pending (unsubmitted) -# review is looked up first, because REST answers 404 for a comment inside it. -# A match is edited with the updatePullRequestReviewComment GraphQL mutation; -# no match falls through to the PATCH above. A failed lookup, a failed -# mutation, or a pending review of more than 100 comments with no match is an -# error and makes no further call. -# A leading https://github.com/, http://github.com/, or https://www.github.com/ -# is stripped; any other scheme or host is rejected. +# from the anchor of the URL; REFERENCE.md lists the accepted URL forms. A URL +# that matches none fails before any gh call. A conversation or inline comment +# is read first and edited only when it belongs to the issue or PR number in +# the URL. An inline comment the REST API reports as 404 may sit in the +# caller's own pending review, so it is looked up there and edited through +# GraphQL. Every other failure ends the call without a write. +# Arguments: +# $1 JSON arguments: url (required) and body (required). +# Outputs: +# The edited comment's URL on success, otherwise an "Error: ..." message. +# Returns: +# 0 when the comment was edited; otherwise gh's exit status, or 1 when the +# failure did not come from a gh exit. +####################################### tool_comment_edit() { local args="$1" @@ -289,17 +326,25 @@ tool_comment_edit() { return 1 fi - local endpoint method review_comment_id="" + # owner_field names the field of the GET response that holds the issue or + # PR the comment belongs to; owner_path is how that URL must end. + local endpoint method owner_field="" owner_path="" review_comment_id="" if [[ "${anchor}" =~ ^issuecomment-([0-9]+)$ && -z "${suffix}" ]]; then endpoint="repos/${repo}/issues/comments/${BASH_REMATCH[1]}" method="PATCH" + owner_field="issue_url" + owner_path="issues/${number}" elif [[ "${kind}" == "pull" && -z "${suffix}" && "${anchor}" =~ ^discussion_r([0-9]+)$ ]]; then endpoint="repos/${repo}/pulls/comments/${BASH_REMATCH[1]}" method="PATCH" + owner_field="pull_request_url" + owner_path="pulls/${number}" review_comment_id="${BASH_REMATCH[1]}" elif [[ "${kind}" == "pull" && -n "${suffix}" && "${anchor}" =~ ^r([0-9]+)$ ]]; then endpoint="repos/${repo}/pulls/comments/${BASH_REMATCH[1]}" method="PATCH" + owner_field="pull_request_url" + owner_path="pulls/${number}" review_comment_id="${BASH_REMATCH[1]}" elif [[ "${kind}" == "pull" && -z "${suffix}" && "${anchor}" =~ ^pullrequestreview-([0-9]+)$ ]]; then endpoint="repos/${repo}/pulls/${number}/reviews/${BASH_REMATCH[1]}" @@ -309,82 +354,100 @@ tool_comment_edit() { return 1 fi - local __raw __exit=0 + local __out __err __exit=0 + local in_pending_review=false + + if [[ -n "${owner_field}" ]]; then + log "INFO" "comment_edit: GET ${endpoint}" + _gh_capture_split __out __err gh api "${endpoint}" || __exit=$? + if [[ ${__exit} -ne 0 ]]; then + # Only an inline comment can still be edited: REST answers 404 for + # a comment inside the caller's unsubmitted review. + if [[ -n "${review_comment_id}" && "${__err}" == *"(HTTP 404)"* ]]; then + in_pending_review=true + __exit=0 + else + echo "Error: comment_edit: GET ${endpoint} failed: ${__err:-${__out:-gh exited with status ${__exit} and no output}}" + return "${__exit}" + fi + else + local owner_url + if ! owner_url=$(printf '%s' "${__out}" | jq -r --arg field "${owner_field}" '.[$field] // empty' 2>/dev/null) || [[ -z "${owner_url}" ]]; then + echo "Error: comment_edit: GET ${endpoint} returned no ${owner_field}: ${__out}" + return 1 + fi + if [[ "${owner_url}" != */"${owner_path}" ]]; then + echo "Error: comment_edit: the comment belongs to #${owner_url##*/}, not to #${number} named in the URL; nothing was edited" + return 1 + fi + fi + fi - if [[ -n "${review_comment_id}" ]]; then + if [[ "${in_pending_review}" == "true" ]]; then local owner="${repo%%/*}" name="${repo##*/}" - local -a lookup_cmd=("gh" "api" "graphql" - "-f" "query=query(\$owner: String!, \$name: String!, \$number: Int!) { repository(owner: \$owner, name: \$name) { pullRequest(number: \$number) { reviews(states: [PENDING], first: 1) { nodes { comments(first: 100) { nodes { id databaseId } pageInfo { hasNextPage } } } } } } }" - "-f" "owner=${owner}" "-f" "name=${name}" "-F" "number=${number}" - ) - - log "INFO" "comment_edit: pending-review lookup: ${lookup_cmd[*]}" - __raw=$("${lookup_cmd[@]}" 2>&1) || __exit=$? - local lookup_error="" + local lookup + log "INFO" "comment_edit: pending-review lookup for comment ${review_comment_id} on PR ${number}" + _comment_edit_graphql lookup "pending-review lookup" gh api graphql \ + -f "query=query(\$owner: String!, \$name: String!, \$number: Int!) { repository(owner: \$owner, name: \$name) { pullRequest(number: \$number) { reviews(states: [PENDING], first: 100) { pageInfo { hasNextPage } nodes { viewerDidAuthor comments(first: 100) { nodes { id fullDatabaseId } pageInfo { hasNextPage } } } } } } }" \ + -f "owner=${owner}" -f "name=${name}" -F "number=${number}" || __exit=$? if [[ ${__exit} -ne 0 ]]; then - lookup_error="${__raw:-gh api graphql failed with exit ${__exit} and no output}" - elif [[ -z "${__raw}" ]]; then - lookup_error="GitHub returned no output" - elif ! lookup_error=$(printf '%s' "${__raw}" | jq -r 'if (.errors // empty) then "GraphQL errors: \(.errors | tostring)" elif (.data.repository.pullRequest | type) != "object" then "response has no pull request data" else empty end' 2>&1); then - lookup_error="unparseable response: ${__raw}" + echo "${lookup}" + return "${__exit}" fi - if [[ -n "${lookup_error}" || ${__exit} -ne 0 ]]; then - [[ "${__exit}" -ne 0 ]] || __exit=1 - echo "Error: pending-review lookup failed: ${lookup_error}" - return ${__exit} + if [[ "$(printf '%s' "${lookup}" | jq -r '.data.repository.pullRequest | type')" != "object" ]]; then + echo "Error: comment_edit: pending-review lookup failed: response has no pull request data: ${lookup}" + return 1 fi - local node_id has_next - node_id=$(printf '%s' "${__raw}" | jq -r --argjson id "${review_comment_id}" \ - '[.data.repository.pullRequest.reviews.nodes[0].comments.nodes[]? | select(.databaseId == $id) | .id][0] // empty') - has_next=$(printf '%s' "${__raw}" | jq -r '.data.repository.pullRequest.reviews.nodes[0].comments.pageInfo.hasNextPage // false') - - if [[ -n "${node_id}" ]]; then - local -a mutation_cmd=("gh" "api" "graphql" - "-f" "query=mutation(\$id: ID!, \$body: String!) { updatePullRequestReviewComment(input: {pullRequestReviewCommentId: \$id, body: \$body}) { pullRequestReviewComment { url } } }" - "-f" "id=${node_id}" "-f" "body=${body}" - ) - log "INFO" "comment_edit: pending-review mutation: ${mutation_cmd[*]}" - __exit=0 - __raw=$("${mutation_cmd[@]}" 2>&1) || __exit=$? - local mutation_error="" mutation_url="" - if [[ ${__exit} -ne 0 ]]; then - mutation_error="${__raw:-gh api graphql failed with exit ${__exit} and no output}" - elif [[ -z "${__raw}" ]]; then - mutation_error="GitHub returned no URL for the edited comment" - elif ! mutation_error=$(printf '%s' "${__raw}" | jq -r 'if (.errors // empty) then "GraphQL errors: \(.errors | tostring)" else empty end' 2>&1); then - mutation_error="unparseable response: ${__raw}" - elif [[ -z "${mutation_error}" ]]; then - mutation_url=$(printf '%s' "${__raw}" | jq -r '.data.updatePullRequestReviewComment.pullRequestReviewComment.url // empty') - [[ -n "${mutation_url}" && "${mutation_url}" != "null" ]] || mutation_error="GitHub returned no URL for the edited comment: ${__raw}" - fi - if [[ -n "${mutation_error}" ]]; then - [[ "${__exit}" -ne 0 ]] || __exit=1 - echo "Error: pending-review comment edit failed: ${mutation_error}" - return ${__exit} + # Only the caller's own pending review counts; GitHub allows one per PR. + # fullDatabaseId is a BigInt that GitHub serializes as a string. + local node_id has_next reviews_next own_count + own_count=$(printf '%s' "${lookup}" | jq -r '[.data.repository.pullRequest.reviews.nodes[]? | select(.viewerDidAuthor == true)] | length') + reviews_next=$(printf '%s' "${lookup}" | jq -r '.data.repository.pullRequest.reviews.pageInfo.hasNextPage // false') + if [[ "${own_count}" == "0" && "${reviews_next}" == "true" ]]; then + echo "Error: comment_edit: comment ${review_comment_id} is not a submitted review comment on ${repo}, and your pending review on PR ${number} could not be located among more than 100 pending reviews" + return 1 + fi + node_id=$(printf '%s' "${lookup}" | jq -r --arg id "${review_comment_id}" \ + '[.data.repository.pullRequest.reviews.nodes[]? | select(.viewerDidAuthor == true) | .comments.nodes[]? | select(.fullDatabaseId == $id) | .id][0] // empty') + has_next=$(printf '%s' "${lookup}" | jq -r '[.data.repository.pullRequest.reviews.nodes[]? | select(.viewerDidAuthor == true) | .comments.pageInfo.hasNextPage][0] // false') + + if [[ -z "${node_id}" ]]; then + if [[ "${has_next}" == "true" ]]; then + echo "Error: comment_edit: comment ${review_comment_id} is not a submitted review comment on ${repo}, and your pending review on PR ${number} has more than 100 comments, so it could not be searched in full" + return 1 fi - echo "${mutation_url}" - return 0 + echo "Error: comment_edit: comment ${review_comment_id} is not a submitted review comment on ${repo}, and it is not in your pending review on PR ${number}" + return 1 fi - if [[ "${has_next}" == "true" ]]; then - echo "Error: the pending review has more than 100 comments and comment ${review_comment_id} could not be located" + local mutation mutation_url + log "INFO" "comment_edit: pending-review mutation for ${node_id}" + _comment_edit_graphql mutation "pending-review comment edit" gh api graphql \ + -f "query=mutation(\$id: ID!, \$body: String!) { updatePullRequestReviewComment(input: {pullRequestReviewCommentId: \$id, body: \$body}) { pullRequestReviewComment { url } } }" \ + -f "id=${node_id}" -f "body=${body}" || __exit=$? + if [[ ${__exit} -ne 0 ]]; then + echo "${mutation}" + return "${__exit}" + fi + mutation_url=$(printf '%s' "${mutation}" | jq -r '.data.updatePullRequestReviewComment.pullRequestReviewComment.url // empty') + if [[ -z "${mutation_url}" ]]; then + echo "Error: comment_edit: pending-review comment edit failed: GitHub returned no URL for the edited comment: ${mutation}" return 1 fi - __exit=0 + echo "${mutation_url}" + return 0 fi - local -a cmd=("gh" "api" "${endpoint}" "-X" "${method}" "-f" "body=${body}" "--jq" ".html_url // empty") - - log "INFO" "comment_edit: ${cmd[*]}" - __raw=$("${cmd[@]}" 2>&1) || __exit=$? + log "INFO" "comment_edit: ${method} ${endpoint}" + _gh_capture_split __out __err gh api "${endpoint}" -X "${method}" -f "body=${body}" --jq ".html_url // empty" || __exit=$? if [[ ${__exit} -ne 0 ]]; then - echo "${__raw:-comment_edit: gh api ${endpoint} failed with exit ${__exit} and no output}" - return ${__exit} + echo "Error: comment_edit: ${method} ${endpoint} failed: ${__err:-${__out:-gh exited with status ${__exit} and no output}}" + return "${__exit}" fi - if [[ -z "${__raw}" || "${__raw}" == "null" ]]; then + if [[ -z "${__out}" || "${__out}" == "null" ]]; then echo "Error: comment_edit: GitHub returned no URL for the edited comment" return 1 fi - echo "${__raw}" + echo "${__out}" } From 695d27c5ebf4f82553a56238ce592c6dbeedb44b Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Tue, 6 Oct 2026 18:21:09 +0200 Subject: [PATCH 3/5] fix(github-mcp): page through pending comments and tighten comment_edit URLs A pending review with more than 100 comments made `comment_edit` return an error instead of finding the comment, so an edit the tool could complete failed. The lookup now reads further pages of the caller's pending review by `endCursor`, and errors when a page fails, when `hasNextPage` comes with no cursor, or when a cursor it already used comes back, which stops both a repeated cursor and a cycle such as A, B, A. The URL parser rejected owners with `_`, which Enterprise Managed User accounts carry (`mona_octocorp`), and accepted numbers with leading zeros that then failed the ownership check with a contradictory message. Owners may now contain `_`, and a number or ID starting with `0` is rejected before any request. When the inline-comment read answers 404 and the lookup fails or finds nothing, the error now states the 404 as well, so a typo in the repository name no longer shows up only as a GraphQL message. Every gh call runs through one helper that builds the error text in one place, and the mutation's jq call is guarded so the output always starts with `Error:`. The tool description names all accepted URL prefixes and says it returns the edited comment's URL, which matches the pending-review path. Two tests that only matched query text are removed. Co-Authored-By: Claude --- .../github-mcp/write_tools_review.bats | 253 ++++++++++++++--- plugins/github-mcp/CHANGELOG.md | 22 +- plugins/github-mcp/REFERENCE.md | 6 +- .../mcp-server-gh/lib/review_write.sh | 259 +++++++++++++----- .../github-mcp/mcp-server-gh/tools-write.json | 2 +- 5 files changed, 431 insertions(+), 111 deletions(-) diff --git a/plugin-tests/github-mcp/write_tools_review.bats b/plugin-tests/github-mcp/write_tools_review.bats index fb93945..a9a9588 100644 --- a/plugin-tests/github-mcp/write_tools_review.bats +++ b/plugin-tests/github-mcp/write_tools_review.bats @@ -25,6 +25,11 @@ setup() { local call_n call_n=$(find "${BATS_TEST_TMPDIR}" -maxdepth 1 -name 'gh_argv.*' | wc -l | tr -d ' ') printf '%s\0' "$@" > "${BATS_TEST_TMPDIR}/gh_argv.$((call_n + 1))" + # Runaway guard: a looping lookup ends here instead of hanging the suite. + if [[ "${call_n}" -ge 20 ]]; then + echo "stub: more than 20 gh calls" >&2 + return 99 + fi if [[ "$*" == *"--input"* ]]; then cat > "${GH_STDIN_FILE}" fi @@ -34,9 +39,17 @@ setup() { fi # comment_edit's calls, told apart by what the argv names. Each kind has # its own stdout, stderr, and exit code: GH_STUB__OUTPUT/_STDERR/_EXIT. + # A page fetch of a pending review's comments is keyed by the cursor it + # sends, so each page has its own canned answer: GH_STUB_PAGE__*. local kind="" get_re='^api repos/[^ ]+/comments/[0-9]+$' if [[ "$*" == *"reviews(states: [PENDING]"* ]]; then kind=LOOKUP + elif [[ "$*" == *'node(id: $reviewId)'* ]]; then + local arg cursor="" + for arg in "$@"; do + [[ "${arg}" == cursor=* ]] && cursor="${arg#cursor=}" + done + kind="PAGE_${cursor}" elif [[ "$*" == *"updatePullRequestReviewComment"* ]]; then kind=MUTATION elif [[ "$*" == *" -X PATCH "* || "$*" == *" -X PUT "* ]]; then @@ -301,7 +314,10 @@ WARNING_STDERR="warning: a new gh release is available" PENDING_NONE='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[]}}}}}' PENDING_MATCH='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_other","fullDatabaseId":"4000"},{"id":"PRRC_node5551","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' PENDING_BIG='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_near","fullDatabaseId":"4195291200"},{"id":"PRRC_big","fullDatabaseId":"4195291201"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' -PENDING_FULL_PAGE='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_a","fullDatabaseId":"1"}],"pageInfo":{"hasNextPage":true}}}]}}}}}' +PENDING_PAGED='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"id":"PRR_mine","viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_a","fullDatabaseId":"1"}],"pageInfo":{"hasNextPage":true,"endCursor":"CUR1"}}}]}}}}}' +PAGE_MORE='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_b","fullDatabaseId":"2"}],"pageInfo":{"hasNextPage":true,"endCursor":"CUR2"}}}}}' +PAGE_LAST_MATCH='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_node5551","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false,"endCursor":"CURLAST"}}}}}' +PAGE_LAST_NO_MATCH='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_c","fullDatabaseId":"3"}],"pageInfo":{"hasNextPage":false,"endCursor":"CURLAST"}}}}}' MUTATION_OK='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":"https://github.com/shopware/shopware/pull/100#discussion_r5551"}}}}' # The GET answer for a comment that belongs to issue or PR N. @@ -369,6 +385,11 @@ assert_no_rest_write() { ! grep -q -- " -X " "${GH_ARGS_FILE}" || fail "Expected no REST write, got: $(cat "${GH_ARGS_FILE}")" } +# The edit mutation was not sent. +assert_no_mutation() { + ! grep -q -- "updatePullRequestReviewComment" "${GH_ARGS_FILE}" || fail "Expected no mutation, got: $(cat "${GH_ARGS_FILE}")" +} + assert_no_graphql_call() { ! grep -q -- "graphql" "${GH_ARGS_FILE}" || fail "Expected no GraphQL call, got: $(cat "${GH_ARGS_FILE}")" } @@ -452,6 +473,16 @@ assert_error_output() { assert_no_graphql_call } +@test "comment_edit names the failed read and keeps gh's exit status when the read fails with no output" { + GH_STUB_GET_EXIT=3 + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + assert_failure 3 + assert_error_output + assert_output --partial "GET repos/shopware/shopware/issues/comments/9001 failed: gh failed with exit 3 and no output" + assert_gh_call_count 1 + assert_no_rest_write +} + @test "comment_edit fails when the read of a conversation comment names no issue, and does not patch" { GH_STUB_GET_OUTPUT='{"id":9001}' run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' @@ -559,15 +590,6 @@ assert_error_output() { assert_no_rest_write } -@test "comment_edit asks GitHub for fullDatabaseId, the field that holds an id above 32 bits" { - GH_STUB_GET_EXIT=1 - GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT="${PENDING_NONE}" - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_gh_call_contains_all 2 "nodes { id fullDatabaseId }" -} - @test "comment_edit fails when the 404 comment is not in the pending review, with no mutation or write" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" @@ -575,10 +597,11 @@ assert_error_output() { run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r9999", "body": "x"}' assert_failure assert_error_output - assert_output --partial "not a submitted review comment" - assert_output --partial "pending review on PR 100" + assert_output --partial "GET repos/shopware/shopware/pulls/comments/9999 returned 404" + assert_output --partial "pending-review lookup on PR 100: no matching comment" assert_gh_call_count 2 assert_no_rest_write + assert_no_mutation } @test "comment_edit fails when the 404 comment is not found and the caller has no pending review" { @@ -588,20 +611,98 @@ assert_error_output() { run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_failure assert_error_output + assert_output --partial "returned 404" + assert_output --partial "pending-review lookup on PR 100: no matching comment (you have no pending review on this PR)" assert_gh_call_count 2 assert_no_rest_write + assert_no_mutation +} + +@test "comment_edit finds the comment on the second page of a large pending review and edits it with that node id" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_PAGED}" + GH_STUB_PAGE_CUR1_OUTPUT="${PAGE_LAST_MATCH}" + GH_STUB_MUTATION_OUTPUT="${MUTATION_OK}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "New text"}' + assert_success + assert_output "https://github.com/shopware/shopware/pull/100#discussion_r5551" + assert_gh_call_count 4 + assert_gh_call_contains_all 3 "-f reviewId=PRR_mine" "-f cursor=CUR1" + assert_gh_last_args_contain_all "updatePullRequestReviewComment" "-f id=PRRC_node5551" "-f body=New text" + assert_no_rest_write } -@test "comment_edit fails when the 404 comment is not on the first page of a large pending review" { +@test "comment_edit follows each page's cursor until the comment turns up on the third page" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT="${PENDING_FULL_PAGE}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_PAGED}" + GH_STUB_PAGE_CUR1_OUTPUT="${PAGE_MORE}" + GH_STUB_PAGE_CUR2_OUTPUT="${PAGE_LAST_MATCH}" + GH_STUB_MUTATION_OUTPUT="${MUTATION_OK}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_success + assert_gh_call_count 5 + assert_gh_call_contains_all 3 "-f reviewId=PRR_mine" "-f cursor=CUR1" + assert_gh_call_contains_all 4 "-f reviewId=PRR_mine" "-f cursor=CUR2" + assert_gh_last_args_contain_all "-f id=PRRC_node5551" +} + +@test "comment_edit fails with the 404 and no matching comment when no page of a large pending review holds it" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_PAGED}" + GH_STUB_PAGE_CUR1_OUTPUT="${PAGE_LAST_NO_MATCH}" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_failure assert_error_output - assert_output --partial "more than 100 comments" - assert_gh_call_count 2 + assert_output --partial "returned 404" + assert_output --partial "pending-review lookup on PR 100: no matching comment" + assert_gh_call_count 3 + assert_no_rest_write + assert_no_mutation +} + +@test "comment_edit fails and sends no mutation when the second page of comments cannot be read" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_PAGED}" + GH_STUB_PAGE_CUR1_EXIT=7 + GH_STUB_PAGE_CUR1_STDERR="gh: Bad Gateway (HTTP 502)" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure 7 + assert_error_output + assert_output --partial "returned 404" + assert_output --partial "Bad Gateway (HTTP 502)" + assert_gh_call_count 3 assert_no_rest_write + assert_no_mutation +} + +@test "comment_edit fails and sends no mutation when the second page carries GraphQL errors" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_PAGED}" + GH_STUB_PAGE_CUR1_OUTPUT='{"errors":[{"message":"Rate limited"}],"data":null}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "Rate limited" + assert_gh_call_count 3 + assert_no_mutation +} + +@test "comment_edit fails and sends no mutation when the second page has no comments object" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_PAGED}" + GH_STUB_PAGE_CUR1_OUTPUT='{"data":{"node":null}}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "no comments for your pending review" + assert_gh_call_count 3 + assert_no_mutation } @test "comment_edit edits a matching pending-review comment even when the review has further pages" { @@ -621,9 +722,11 @@ assert_error_output() { run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_failure assert_error_output - assert_output --partial "not in your pending review" + assert_output --partial "returned 404" + assert_output --partial "pending-review lookup on PR 100: no matching comment (it is not in your pending review)" assert_gh_call_count 2 assert_no_rest_write + assert_no_mutation } @test "comment_edit edits with the node id from the caller's review when it is not the first pending review" { @@ -644,9 +747,10 @@ assert_error_output() { run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_failure assert_error_output - assert_output --partial "not in your pending review" + assert_output --partial "pending-review lookup on PR 100: no matching comment (you have no pending review on this PR)" assert_gh_call_count 2 assert_no_rest_write + assert_no_mutation } @test "comment_edit fails when the caller's review is not among more than 100 pending reviews" { @@ -656,32 +760,37 @@ assert_error_output() { run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_failure assert_error_output - assert_output --partial "more than 100 pending reviews" + assert_output --partial "returned 404" + assert_output --partial "pending-review lookup on PR 100: your pending review could not be located among more than 100 pending reviews" assert_gh_call_count 2 assert_no_rest_write } -@test "comment_edit asks GitHub for up to 100 pending reviews with their viewerDidAuthor flag" { +@test "comment_edit fails when the pending-review lookup exits non-zero, with gh's message and no mutation" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT="${PENDING_NONE}" + GH_STUB_LOOKUP_EXIT=1 + GH_STUB_LOOKUP_STDERR="gh: Could not resolve to a Repository" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_failure - assert_gh_call_contains_all 2 "reviews(states: [PENDING], first: 100) { pageInfo { hasNextPage } nodes { viewerDidAuthor comments" + assert_error_output + assert_output --partial "GET repos/shopware/shopware/pulls/comments/5551 returned 404 (not found, or no access to the repository)" + assert_output --partial "pending-review lookup on PR 100: gh: Could not resolve to a Repository" + assert_gh_call_count 2 + assert_no_rest_write } -@test "comment_edit fails when the pending-review lookup exits non-zero, with gh's message and no mutation" { +@test "comment_edit states the 404 and the GraphQL message when the lookup answers a Could not resolve error" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_EXIT=1 - GH_STUB_LOOKUP_STDERR="gh: Could not resolve to a Repository" + GH_STUB_LOOKUP_OUTPUT='{"errors":[{"type":"NOT_FOUND","message":"Could not resolve to a Repository with the name shopware/shopware."}],"data":{"repository":null}}' run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_failure assert_error_output - assert_output --partial "pending-review lookup failed" + assert_output --partial "returned 404 (not found, or no access to the repository)" assert_output --partial "Could not resolve to a Repository" assert_gh_call_count 2 - assert_no_rest_write + assert_no_mutation } @test "comment_edit fails when the lookup response carries GraphQL errors" { @@ -691,6 +800,7 @@ assert_error_output() { run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_failure assert_error_output + assert_output --partial "returned 404" assert_output --partial "Something broke" assert_gh_call_count 2 } @@ -702,7 +812,7 @@ assert_error_output() { run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_failure assert_error_output - assert_output --partial "pending-review lookup failed: GitHub returned no output" + assert_output --partial "pending-review lookup on PR 100: GitHub returned no output" assert_gh_call_count 2 } @@ -713,7 +823,7 @@ assert_error_output() { run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_failure 5 assert_error_output - assert_output --partial "gh api graphql failed with exit 5 and no output" + assert_output --partial "pending-review lookup on PR 100: gh failed with exit 5 and no output" } @test "comment_edit fails when the lookup output is not JSON" { @@ -748,6 +858,7 @@ assert_error_output() { assert_failure assert_error_output assert_output --partial "Resource not accessible" + assert_output --partial "GET repos/shopware/shopware/pulls/comments/5551 returned 404 (not found, or no access to the repository); pending-review comment edit failed" assert_gh_call_count 3 assert_no_rest_write } @@ -761,6 +872,7 @@ assert_error_output() { assert_failure assert_error_output assert_output --partial "Body cannot be blank" + assert_output --partial "GET repos/shopware/shopware/pulls/comments/5551 returned 404 (not found, or no access to the repository); pending-review comment edit failed" assert_no_rest_write } @@ -773,6 +885,7 @@ assert_error_output() { assert_failure assert_error_output assert_output --partial "pending-review comment edit failed" + assert_output --partial "GET repos/shopware/shopware/pulls/comments/5551 returned 404 (not found, or no access to the repository); pending-review comment edit failed" } @test "comment_edit fails when the mutation response has a null url" { @@ -784,6 +897,47 @@ assert_error_output() { assert_failure assert_error_output assert_output --partial "no URL for the edited comment" + assert_output --partial "GET repos/shopware/shopware/pulls/comments/5551 returned 404 (not found, or no access to the repository); pending-review comment edit failed" +} + +@test "comment_edit fails when the mutation response has a wrongly typed data path, with no jq diagnostic" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":"oops"}}' + run --separate-stderr tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + [[ "${lines[0]}" == Error:* ]] || fail "Expected first line to start with Error:, got: ${lines[0]}" + [[ -z "${stderr}" ]] || fail "Expected no stderr, got: ${stderr}" +} + +@test "comment_edit fails without a mutation when GitHub returns the cursor just used, after a bounded number of calls" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_PAGED}" + GH_STUB_PAGE_CUR1_OUTPUT='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_b","fullDatabaseId":"2"}],"pageInfo":{"hasNextPage":true,"endCursor":"CUR1"}}}}}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "did not advance" + assert_gh_call_count 3 + assert_no_rest_write + assert_no_mutation +} + +@test "comment_edit fails without a mutation when pages alternate between two cursors, after a bounded number of calls" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_PAGED}" + GH_STUB_PAGE_CUR1_OUTPUT="${PAGE_MORE}" + GH_STUB_PAGE_CUR2_OUTPUT='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_c","fullDatabaseId":"3"}],"pageInfo":{"hasNextPage":true,"endCursor":"CUR1"}}}}}' + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "did not advance (cursor CUR1 came back)" + assert_gh_call_count 4 + assert_no_rest_write + assert_no_mutation } @test "comment_edit names the failed call and keeps gh's exit status when the mutation fails with no output" { @@ -794,7 +948,8 @@ assert_error_output() { run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_failure 4 assert_error_output - assert_output --partial "gh api graphql failed with exit 4 and no output" + assert_output --partial "pending-review comment edit failed: gh failed with exit 4 and no output" + assert_output --partial "GET repos/shopware/shopware/pulls/comments/5551 returned 404 (not found, or no access to the repository); pending-review comment edit failed" } # ---- review summary body ------------------------------------------------------ @@ -861,6 +1016,42 @@ assert_error_output() { done } +@test "comment_edit accepts an owner with an underscore and reaches gh" { + GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/mona_octocorp/repo/issues/7"}' + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "mona_octocorp/repo/issues/7#issuecomment-1", "body": "x"}' + assert_success + assert_output "https://example/c" + assert_gh_call_equal 1 "api repos/mona_octocorp/repo/issues/comments/1" + assert_gh_call_equal 2 "api repos/mona_octocorp/repo/issues/comments/1 -X PATCH -f body=x --jq .html_url // empty" +} + +@test "comment_edit rejects an issue or PR number with a leading zero before any gh call" { + local u + for u in "shopware/shopware/issues/07#issuecomment-5" \ + "shopware/shopware/pull/0100#discussion_r5" \ + "shopware/shopware/pull/0#issuecomment-5"; do + run tool_comment_edit "{\"url\": \"${u}\", \"body\": \"x\"}" + [[ "${status}" -ne 0 ]] || fail "Expected '${u}' to be rejected" + [[ "${output}" == "Error: url must be "* ]] || fail "Expected the form error for '${u}', got: ${output}" + done + assert_gh_not_called +} + +@test "comment_edit rejects a comment or review ID with a leading zero before any gh call" { + local u + for u in "shopware/shopware/issues/7#issuecomment-05" \ + "shopware/shopware/pull/100#discussion_r05" \ + "shopware/shopware/pull/100/files#r05" \ + "shopware/shopware/pull/100/changes#r0" \ + "shopware/shopware/pull/100#pullrequestreview-05"; do + run tool_comment_edit "{\"url\": \"${u}\", \"body\": \"x\"}" + [[ "${status}" -ne 0 ]] || fail "Expected '${u}' to be rejected" + [[ "${output}" == "Error: url must be "* ]] || fail "Expected the form error for '${u}', got: ${output}" + done + assert_gh_not_called +} + @test "comment_edit requires url" { run tool_comment_edit '{"body": "x"}' assert_failure @@ -1008,7 +1199,7 @@ assert_error_output() { run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' assert_failure 3 assert_error_output - assert_output --partial "PATCH repos/shopware/shopware/issues/comments/3 failed: gh exited with status 3 and no output" + assert_output --partial "PATCH repos/shopware/shopware/issues/comments/3 failed: gh failed with exit 3 and no output" } @test "comment_edit fails with a message when the PATCH succeeds with no output" { diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index 531f210..7d87419 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -13,16 +13,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 comment's URL: `issues/N#issuecomment-ID` and `pull/N#issuecomment-ID` (conversation comments), `pull/N#discussion_rID` and `pull/N/files#rID` or `pull/N/changes#rID` (inline review comments), and `pull/N#pullrequestreview-ID` (a review's summary body). The URL may carry a leading - `https://github.com/`; a URL on another host, without an anchor, with an unknown anchor, or with a - non-numeric ID or number is rejected before any `gh` call. A conversation or inline comment is read - first and edited only when it belongs to the issue or PR `N` in the URL; a comment of another - issue or PR is an error and nothing is edited. When the read of an inline comment answers 404, the - comment may sit in the caller's own unsubmitted (pending) review: the tool looks the review up - through GraphQL, matching `fullDatabaseId`, and edits a matching comment with the - `updatePullRequestReviewComment` mutation. No match is an error. Every failure is returned as an - error that starts with `Error:`, and a warning that `gh` writes to stderr on a successful call does - not fail the edit. It returns the updated comment's `html_url`, or the `url` for a comment in a - pending review. + `https://github.com/`; a URL on another host, without an anchor, with an unknown anchor, with a + non-numeric number or ID, or with a number or ID that starts with `0` is rejected before any `gh` + call. The owner may contain `_`, as an Enterprise Managed User account name does. A conversation or + inline comment is read first and edited only when it belongs to the issue or PR `N` in the URL; a + comment of another issue or PR is an error and nothing is edited. When the read of an inline + comment answers 404, the comment may sit in the caller's own unsubmitted (pending) review: the tool + looks the review up through GraphQL, matching `fullDatabaseId` and reading further pages of a + review with more than 100 comments, and edits a matching comment with the + `updatePullRequestReviewComment` mutation. When the lookup fails or finds no match, the error + states the 404 and the lookup's result. Every failure is returned as an error that starts with + `Error:`, and a warning that `gh` writes to stderr on a successful call does not fail the edit. It + returns the updated comment's `html_url`, or the `url` for a comment in a pending review. - `check-api-tools.sh` redirects `PATCH repos/O/R/issues/comments/ID`, `PATCH repos/O/R/pulls/comments/ID`, and `PUT repos/O/R/pulls/N/reviews/ID` on the `api` write tool to `comment_edit`, under `block_api_tool_write`. The message for a conversation comment points to diff --git a/plugins/github-mcp/REFERENCE.md b/plugins/github-mcp/REFERENCE.md index a7950a4..9b9adcc 100644 --- a/plugins/github-mcp/REFERENCE.md +++ b/plugins/github-mcp/REFERENCE.md @@ -742,7 +742,7 @@ Use gh-tooling-write comment_edit with url "https://github.com/shopware/shopware Use gh-tooling-write comment_edit with url "shopware/shopware/pull/14642#discussion_r1234567" and body "Fixed in b8f9a8c." ``` -Accepted `url` forms, each with or without a leading `https://github.com/` (`http://github.com/` and `https://www.github.com/` also work; any other scheme or host is rejected). `N` and `ID` are digits only: +Accepted `url` forms, each with or without a leading `https://github.com/` (`http://github.com/` and `https://www.github.com/` also work; any other scheme or host is rejected). `OWNER` is letters, digits, `-`, and `_` (an Enterprise Managed User account such as `mona_octocorp` has one). `N` and `ID` are digits only and do not start with `0`: | `url` form | Edits | Request | |--------------------------------------------------------|---------------------------------|-------------------------------------------------| @@ -754,11 +754,11 @@ Accepted `url` forms, each with or without a leading `https://github.com/` (`htt Before editing a conversation comment or an inline review comment, the tool reads it (`GET`) and checks that it belongs to the issue or PR `N` in the URL: the comment's `issue_url` must end in `/issues/N` (PR conversation comments also use `/issues/N`), or its `pull_request_url` in `/pulls/N`. A comment of another issue or PR is an error naming both numbers, and nothing is edited. A failed read, including a 404 on a conversation comment, is an error with gh's message and no edit. -For the two inline review comment forms, a 404 on that read means the comment is not a submitted one. It can still be in your own unsubmitted (pending) review, which the REST endpoint does not return. The tool then looks up your pending review on PR `N` through GraphQL (among up to 100 pending reviews, only the one you wrote), matching the comment's `fullDatabaseId`, and edits a match with the `updatePullRequestReviewComment` mutation. It returns the comment's `url`. No match is an error: the comment is not a submitted review comment on the repository and not in your pending review on PR `N`. A pending review of more than 100 comments in which the comment is not found is an error too. So is a PR with more than 100 pending reviews none of which is yours. A read that fails with anything other than a 404, a failed lookup, and a failed mutation are each an error, and none makes a further call. The review summary form sends its `PUT` without a read. +For the two inline review comment forms, a 404 on that read means the comment is not a submitted one, or the token has no access to the repository. The comment can still be in your own unsubmitted (pending) review, which the REST endpoint does not return. The tool then looks up your pending review on PR `N` through GraphQL (among up to 100 pending reviews, only the one you wrote) and matches the comment's `fullDatabaseId`. When your review has more than 100 comments, the tool reads the further pages until it finds the comment or reaches the last page. It edits a match with the `updatePullRequestReviewComment` mutation and returns the comment's `url`. When the lookup finds no match or fails, the error states both facts: `Error: comment_edit: GET repos/OWNER/REPO/pulls/comments/ID returned 404 (not found, or no access to the repository); pending-review lookup on PR N: `. A failed page is a lookup error. So is a PR with more than 100 pending reviews none of which is yours. A mutation that fails or returns no URL is an error that starts with the same 404 statement, followed by `pending-review comment edit failed: ...`; pages that never advance (GitHub returns a cursor already used in this lookup) are a lookup error. A read that fails with anything other than a 404 is an error too. A failed read, lookup page, or mutation makes no further call. The review summary form sends its `PUT` without a read. Every failure is an error: the output starts with `Error:` and the call fails. Output that a successful `gh` call writes to stderr, such as a warning, does not fail the edit. A failed `gh` call returns its stderr message in the error. -Any other URL fails before the request: no anchor, an unknown anchor, a non-numeric number or ID, `#discussion_rID`, `#rID`, or `#pullrequestreview-ID` on an `issues/N` URL, `#rID` without `/files` or `/changes`, and extra path segments or a query string. The repository comes from the URL, so the tool takes no `repo` parameter. +Any other URL fails before the request: no anchor, an unknown anchor, a non-numeric number or ID, a number or ID with a leading zero, `#discussion_rID`, `#rID`, or `#pullrequestreview-ID` on an `issues/N` URL, `#rID` without `/files` or `/changes`, and extra path segments or a query string. The repository comes from the URL, so the tool takes no `repo` parameter. **Parameters:** - `url` (string, required): URL of the comment to edit, with its anchor. diff --git a/plugins/github-mcp/mcp-server-gh/lib/review_write.sh b/plugins/github-mcp/mcp-server-gh/lib/review_write.sh index e45bae4..8a8f496 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/review_write.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/review_write.sh @@ -232,28 +232,58 @@ tool_pr_review_reply() { } ####################################### -# Run one gh api graphql call for comment_edit and classify the result. A -# non-zero exit, an empty stdout, an `errors` field, or stdout that is not -# JSON each count as a failure. stdout alone is parsed; stderr only explains a -# failed call, so a warning on a successful call never fails it. +# Run one gh command for comment_edit and hand back stdout, stderr, and the +# exit status. Every gh call of the tool goes through here, so a failed call +# is described one way. stdout and stderr stay apart, so a warning on a +# successful call never joins the value. # Arguments: -# $1 name of the caller's variable that receives the result, -# $2 label for the error message (for example "pending-review lookup"), +# $1 name of the caller's variable that receives stdout on success and the +# problem text on failure, +# $2 name of the caller's variable that receives stderr, # $3... the gh command and its arguments. # Outputs: +# Nothing on stdout; the results go to the variables named by $1 and $2. +# Returns: +# 0 on success; otherwise gh's exit status. The problem text is gh's +# stderr, else its stdout (gh api prints an HTTP error's JSON body there), +# else "gh failed with exit N and no output". +####################################### +_comment_edit_gh() { + local __ceh_var="$1" __ceh_err_var="$2" + shift 2 + local __ceh_out __ceh_err __ceh_exit=0 + + _gh_capture_split __ceh_out __ceh_err "$@" || __ceh_exit=$? + printf -v "${__ceh_err_var}" '%s' "${__ceh_err}" + if [[ ${__ceh_exit} -ne 0 ]]; then + printf -v "${__ceh_var}" '%s' "${__ceh_err:-${__ceh_out:-gh failed with exit ${__ceh_exit} and no output}}" + return "${__ceh_exit}" + fi + printf -v "${__ceh_var}" '%s' "${__ceh_out}" +} + +####################################### +# Run one gh api graphql call for comment_edit and classify the result. On top +# of _comment_edit_gh, an empty stdout, an `errors` field, or stdout that is +# not JSON each count as a failure. stdout alone is parsed; stderr only +# explains a failed call, so a warning on a successful call never fails it. +# Arguments: +# $1 name of the caller's variable that receives the result, +# $2... the gh command and its arguments. +# Outputs: # Nothing on stdout; the result goes to the variable named by $1. # Returns: # 0 with the response JSON in the variable. Otherwise gh's exit status (1 -# when gh exited 0) with an "Error: ..." message in the variable. +# when gh exited 0) with the problem text in the variable. ####################################### _comment_edit_graphql() { - local __ceg_var="$1" __ceg_label="$2" - shift 2 + local __ceg_var="$1" + shift local __ceg_out __ceg_err __ceg_exit=0 __ceg_problem="" - _gh_capture_split __ceg_out __ceg_err "$@" || __ceg_exit=$? + _comment_edit_gh __ceg_out __ceg_err "$@" || __ceg_exit=$? if [[ ${__ceg_exit} -ne 0 ]]; then - __ceg_problem="${__ceg_err:-${__ceg_out:-gh api graphql failed with exit ${__ceg_exit} and no output}}" + __ceg_problem="${__ceg_out}" elif [[ -z "${__ceg_out}" ]]; then __ceg_problem="GitHub returned no output" elif ! __ceg_problem=$(printf '%s' "${__ceg_out}" | jq -r 'if (.errors // empty) then "GraphQL errors: \(.errors | tostring)" else empty end' 2>/dev/null); then @@ -262,20 +292,142 @@ _comment_edit_graphql() { if [[ -n "${__ceg_problem}" ]]; then [[ ${__ceg_exit} -ne 0 ]] || __ceg_exit=1 - printf -v "${__ceg_var}" '%s' "Error: comment_edit: ${__ceg_label} failed: ${__ceg_problem}" + printf -v "${__ceg_var}" '%s' "${__ceg_problem}" return "${__ceg_exit}" fi printf -v "${__ceg_var}" '%s' "${__ceg_out}" } +####################################### +# Find a comment in the caller's own pending review on a PR and return its +# GraphQL node ID. Only a review the caller wrote counts (viewerDidAuthor); +# GitHub allows one pending review per author and PR. The first 100 comments +# come with the review lookup, and every further page is fetched until the +# comment matches or the last page ends. fullDatabaseId is a BigInt that +# GitHub serializes as a string. Each response is read by one jq program that +# prints tab-separated fields, with "-" for an empty one so that consecutive +# tabs never collapse. +# Arguments: +# $1 name of the caller's variable that receives the result, +# $2 repository in owner/repo format, +# $3 PR number, +# $4 database ID of the comment. +# Outputs: +# Nothing on stdout; the result goes to the variable named by $1. +# Returns: +# 0 with the node ID in the variable. Otherwise gh's exit status, or 1 when +# the failure did not come from a gh exit, with the problem text in the +# variable. +####################################### +_comment_edit_find_pending() { + local __cfp_var="$1" __cfp_repo="$2" __cfp_number="$3" __cfp_id="$4" + local __cfp_resp __cfp_row __cfp_exit=0 + local pr_ok own_count reviews_next review_id node_id comments_next cursor + local -A used_cursors=() + + # shellcheck disable=SC2016 # jq variables ($pr, $id), not shell vars + local lookup_jq=' + def dash: if . == null or . == "" then "-" else . end; + .data.repository.pullRequest as $pr + | if ($pr | type) != "object" then ["no", "-", "-", "-", "-", "-", "-"] + else + [$pr.reviews.nodes[]? | select(.viewerDidAuthor == true)] as $own + | ($own[0] // {}) as $rev + | ["yes", + ($own | length | tostring), + (($pr.reviews.pageInfo.hasNextPage // false) | tostring), + ($rev.id | dash), + ([$rev.comments.nodes[]? | select(.fullDatabaseId == $id) | .id][0] | dash), + (($rev.comments.pageInfo.hasNextPage // false) | tostring), + ($rev.comments.pageInfo.endCursor | dash)] + end + | @tsv' + # shellcheck disable=SC2016 # jq variables ($c, $id), not shell vars + local page_jq=' + def dash: if . == null or . == "" then "-" else . end; + .data.node.comments as $c + | if ($c | type) != "object" then ["no", "-", "-", "-"] + else + ["yes", + ([$c.nodes[]? | select(.fullDatabaseId == $id) | .id][0] | dash), + (($c.pageInfo.hasNextPage // false) | tostring), + ($c.pageInfo.endCursor | dash)] + end + | @tsv' + + _comment_edit_graphql __cfp_resp gh api graphql \ + -f "query=query(\$owner: String!, \$name: String!, \$number: Int!) { repository(owner: \$owner, name: \$name) { pullRequest(number: \$number) { reviews(states: [PENDING], first: 100) { pageInfo { hasNextPage } nodes { id viewerDidAuthor comments(first: 100) { nodes { id fullDatabaseId } pageInfo { hasNextPage endCursor } } } } } } }" \ + -f "owner=${__cfp_repo%%/*}" -f "name=${__cfp_repo##*/}" -F "number=${__cfp_number}" || __cfp_exit=$? + if [[ ${__cfp_exit} -ne 0 ]]; then + printf -v "${__cfp_var}" '%s' "${__cfp_resp}" + return "${__cfp_exit}" + fi + if ! __cfp_row=$(printf '%s' "${__cfp_resp}" | jq -r --arg id "${__cfp_id}" "${lookup_jq}" 2>/dev/null); then + printf -v "${__cfp_var}" '%s' "unreadable response: ${__cfp_resp}" + return 1 + fi + IFS=$'\t' read -r pr_ok own_count reviews_next review_id node_id comments_next cursor <<< "${__cfp_row}" + + if [[ "${pr_ok}" != "yes" ]]; then + printf -v "${__cfp_var}" '%s' "response has no pull request data: ${__cfp_resp}" + return 1 + fi + if [[ "${own_count}" == "0" ]]; then + if [[ "${reviews_next}" == "true" ]]; then + printf -v "${__cfp_var}" '%s' "your pending review could not be located among more than 100 pending reviews" + else + printf -v "${__cfp_var}" '%s' "no matching comment (you have no pending review on this PR)" + fi + return 1 + fi + + while [[ "${node_id}" == "-" && "${comments_next}" == "true" ]]; do + if [[ "${review_id}" == "-" || "${cursor}" == "-" ]]; then + printf -v "${__cfp_var}" '%s' "your pending review has more comments but GitHub gave no cursor to read them: ${__cfp_resp}" + return 1 + fi + log "INFO" "comment_edit: next page of comments in pending review ${review_id}" + used_cursors["${cursor}"]=1 + __cfp_exit=0 + _comment_edit_graphql __cfp_resp gh api graphql \ + -f "query=query(\$reviewId: ID!, \$cursor: String!) { node(id: \$reviewId) { ... on PullRequestReview { comments(first: 100, after: \$cursor) { nodes { id fullDatabaseId } pageInfo { hasNextPage endCursor } } } } }" \ + -f "reviewId=${review_id}" -f "cursor=${cursor}" || __cfp_exit=$? + if [[ ${__cfp_exit} -ne 0 ]]; then + printf -v "${__cfp_var}" '%s' "reading the next comments of your pending review failed: ${__cfp_resp}" + return "${__cfp_exit}" + fi + if ! __cfp_row=$(printf '%s' "${__cfp_resp}" | jq -r --arg id "${__cfp_id}" "${page_jq}" 2>/dev/null); then + printf -v "${__cfp_var}" '%s' "unreadable response: ${__cfp_resp}" + return 1 + fi + IFS=$'\t' read -r pr_ok node_id comments_next cursor <<< "${__cfp_row}" + if [[ "${pr_ok}" != "yes" ]]; then + printf -v "${__cfp_var}" '%s' "response has no comments for your pending review: ${__cfp_resp}" + return 1 + fi + if [[ "${node_id}" == "-" && "${comments_next}" == "true" && -n "${used_cursors[${cursor}]:-}" ]]; then + printf -v "${__cfp_var}" '%s' "the comment pages of your pending review did not advance (cursor ${cursor} came back): ${__cfp_resp}" + return 1 + fi + done + + if [[ "${node_id}" == "-" ]]; then + printf -v "${__cfp_var}" '%s' "no matching comment (it is not in your pending review)" + return 1 + fi + printf -v "${__cfp_var}" '%s' "${node_id}" +} + ####################################### # Replace the body of one existing comment, whatever its kind. The kind comes # from the anchor of the URL; REFERENCE.md lists the accepted URL forms. A URL # that matches none fails before any gh call. A conversation or inline comment # is read first and edited only when it belongs to the issue or PR number in # the URL. An inline comment the REST API reports as 404 may sit in the -# caller's own pending review, so it is looked up there and edited through -# GraphQL. Every other failure ends the call without a write. +# caller's own pending review, so it is looked up there, page by page, and +# edited through GraphQL; when that lookup fails or finds nothing, the error +# states the 404 and the lookup's result. Every other failure ends the call +# without a write. # Arguments: # $1 JSON arguments: url (required) and body (required). # Outputs: @@ -313,8 +465,10 @@ tool_comment_edit() { return 1 fi + # Owners may contain "_" (Enterprise Managed User accounts). GitHub never + # generates a number or ID with a leading zero, so none is accepted. local form_error="Error: url must be OWNER/REPO/issues/N#issuecomment-ID, OWNER/REPO/pull/N#issuecomment-ID, OWNER/REPO/pull/N#discussion_rID, OWNER/REPO/pull/N/files#rID, OWNER/REPO/pull/N/changes#rID, or OWNER/REPO/pull/N#pullrequestreview-ID (optionally with a leading https://github.com/), got: '${url}'" - local url_re='^([A-Za-z0-9-]+/[A-Za-z0-9_.-]+)/(issues|pull)/([0-9]+)(/files|/changes)?#([A-Za-z0-9_-]+)$' + local url_re='^([A-Za-z0-9_-]+/[A-Za-z0-9_.-]+)/(issues|pull)/([1-9][0-9]*)(/files|/changes)?#([A-Za-z0-9_-]+)$' if [[ ! "${rest}" =~ ${url_re} ]]; then echo "${form_error}" return 1 @@ -329,24 +483,24 @@ tool_comment_edit() { # owner_field names the field of the GET response that holds the issue or # PR the comment belongs to; owner_path is how that URL must end. local endpoint method owner_field="" owner_path="" review_comment_id="" - if [[ "${anchor}" =~ ^issuecomment-([0-9]+)$ && -z "${suffix}" ]]; then + if [[ "${anchor}" =~ ^issuecomment-([1-9][0-9]*)$ && -z "${suffix}" ]]; then endpoint="repos/${repo}/issues/comments/${BASH_REMATCH[1]}" method="PATCH" owner_field="issue_url" owner_path="issues/${number}" - elif [[ "${kind}" == "pull" && -z "${suffix}" && "${anchor}" =~ ^discussion_r([0-9]+)$ ]]; then + elif [[ "${kind}" == "pull" && -z "${suffix}" && "${anchor}" =~ ^discussion_r([1-9][0-9]*)$ ]]; then endpoint="repos/${repo}/pulls/comments/${BASH_REMATCH[1]}" method="PATCH" owner_field="pull_request_url" owner_path="pulls/${number}" review_comment_id="${BASH_REMATCH[1]}" - elif [[ "${kind}" == "pull" && -n "${suffix}" && "${anchor}" =~ ^r([0-9]+)$ ]]; then + elif [[ "${kind}" == "pull" && -n "${suffix}" && "${anchor}" =~ ^r([1-9][0-9]*)$ ]]; then endpoint="repos/${repo}/pulls/comments/${BASH_REMATCH[1]}" method="PATCH" owner_field="pull_request_url" owner_path="pulls/${number}" review_comment_id="${BASH_REMATCH[1]}" - elif [[ "${kind}" == "pull" && -z "${suffix}" && "${anchor}" =~ ^pullrequestreview-([0-9]+)$ ]]; then + elif [[ "${kind}" == "pull" && -z "${suffix}" && "${anchor}" =~ ^pullrequestreview-([1-9][0-9]*)$ ]]; then endpoint="repos/${repo}/pulls/${number}/reviews/${BASH_REMATCH[1]}" method="PUT" else @@ -355,19 +509,19 @@ tool_comment_edit() { fi local __out __err __exit=0 - local in_pending_review=false + local not_found_note="" if [[ -n "${owner_field}" ]]; then log "INFO" "comment_edit: GET ${endpoint}" - _gh_capture_split __out __err gh api "${endpoint}" || __exit=$? + _comment_edit_gh __out __err gh api "${endpoint}" || __exit=$? if [[ ${__exit} -ne 0 ]]; then # Only an inline comment can still be edited: REST answers 404 for # a comment inside the caller's unsubmitted review. if [[ -n "${review_comment_id}" && "${__err}" == *"(HTTP 404)"* ]]; then - in_pending_review=true + not_found_note="GET ${endpoint} returned 404 (not found, or no access to the repository)" __exit=0 else - echo "Error: comment_edit: GET ${endpoint} failed: ${__err:-${__out:-gh exited with status ${__exit} and no output}}" + echo "Error: comment_edit: GET ${endpoint} failed: ${__out}" return "${__exit}" fi else @@ -383,56 +537,29 @@ tool_comment_edit() { fi fi - if [[ "${in_pending_review}" == "true" ]]; then - local owner="${repo%%/*}" name="${repo##*/}" - local lookup + if [[ -n "${not_found_note}" ]]; then + local pending_node_id mutation mutation_url log "INFO" "comment_edit: pending-review lookup for comment ${review_comment_id} on PR ${number}" - _comment_edit_graphql lookup "pending-review lookup" gh api graphql \ - -f "query=query(\$owner: String!, \$name: String!, \$number: Int!) { repository(owner: \$owner, name: \$name) { pullRequest(number: \$number) { reviews(states: [PENDING], first: 100) { pageInfo { hasNextPage } nodes { viewerDidAuthor comments(first: 100) { nodes { id fullDatabaseId } pageInfo { hasNextPage } } } } } } }" \ - -f "owner=${owner}" -f "name=${name}" -F "number=${number}" || __exit=$? + _comment_edit_find_pending pending_node_id "${repo}" "${number}" "${review_comment_id}" || __exit=$? if [[ ${__exit} -ne 0 ]]; then - echo "${lookup}" + echo "Error: comment_edit: ${not_found_note}; pending-review lookup on PR ${number}: ${pending_node_id}" return "${__exit}" fi - if [[ "$(printf '%s' "${lookup}" | jq -r '.data.repository.pullRequest | type')" != "object" ]]; then - echo "Error: comment_edit: pending-review lookup failed: response has no pull request data: ${lookup}" - return 1 - fi - - # Only the caller's own pending review counts; GitHub allows one per PR. - # fullDatabaseId is a BigInt that GitHub serializes as a string. - local node_id has_next reviews_next own_count - own_count=$(printf '%s' "${lookup}" | jq -r '[.data.repository.pullRequest.reviews.nodes[]? | select(.viewerDidAuthor == true)] | length') - reviews_next=$(printf '%s' "${lookup}" | jq -r '.data.repository.pullRequest.reviews.pageInfo.hasNextPage // false') - if [[ "${own_count}" == "0" && "${reviews_next}" == "true" ]]; then - echo "Error: comment_edit: comment ${review_comment_id} is not a submitted review comment on ${repo}, and your pending review on PR ${number} could not be located among more than 100 pending reviews" - return 1 - fi - node_id=$(printf '%s' "${lookup}" | jq -r --arg id "${review_comment_id}" \ - '[.data.repository.pullRequest.reviews.nodes[]? | select(.viewerDidAuthor == true) | .comments.nodes[]? | select(.fullDatabaseId == $id) | .id][0] // empty') - has_next=$(printf '%s' "${lookup}" | jq -r '[.data.repository.pullRequest.reviews.nodes[]? | select(.viewerDidAuthor == true) | .comments.pageInfo.hasNextPage][0] // false') - if [[ -z "${node_id}" ]]; then - if [[ "${has_next}" == "true" ]]; then - echo "Error: comment_edit: comment ${review_comment_id} is not a submitted review comment on ${repo}, and your pending review on PR ${number} has more than 100 comments, so it could not be searched in full" - return 1 - fi - echo "Error: comment_edit: comment ${review_comment_id} is not a submitted review comment on ${repo}, and it is not in your pending review on PR ${number}" - return 1 - fi - - local mutation mutation_url - log "INFO" "comment_edit: pending-review mutation for ${node_id}" - _comment_edit_graphql mutation "pending-review comment edit" gh api graphql \ + log "INFO" "comment_edit: pending-review mutation for ${pending_node_id}" + _comment_edit_graphql mutation gh api graphql \ -f "query=mutation(\$id: ID!, \$body: String!) { updatePullRequestReviewComment(input: {pullRequestReviewCommentId: \$id, body: \$body}) { pullRequestReviewComment { url } } }" \ - -f "id=${node_id}" -f "body=${body}" || __exit=$? + -f "id=${pending_node_id}" -f "body=${body}" || __exit=$? if [[ ${__exit} -ne 0 ]]; then - echo "${mutation}" + echo "Error: comment_edit: ${not_found_note}; pending-review comment edit failed: ${mutation}" return "${__exit}" fi - mutation_url=$(printf '%s' "${mutation}" | jq -r '.data.updatePullRequestReviewComment.pullRequestReviewComment.url // empty') + if ! mutation_url=$(printf '%s' "${mutation}" | jq -r '.data.updatePullRequestReviewComment.pullRequestReviewComment.url // empty' 2>/dev/null); then + echo "Error: comment_edit: ${not_found_note}; pending-review comment edit failed: unreadable response: ${mutation}" + return 1 + fi if [[ -z "${mutation_url}" ]]; then - echo "Error: comment_edit: pending-review comment edit failed: GitHub returned no URL for the edited comment: ${mutation}" + echo "Error: comment_edit: ${not_found_note}; pending-review comment edit failed: GitHub returned no URL for the edited comment: ${mutation}" return 1 fi echo "${mutation_url}" @@ -440,12 +567,12 @@ tool_comment_edit() { fi log "INFO" "comment_edit: ${method} ${endpoint}" - _gh_capture_split __out __err gh api "${endpoint}" -X "${method}" -f "body=${body}" --jq ".html_url // empty" || __exit=$? + _comment_edit_gh __out __err gh api "${endpoint}" -X "${method}" -f "body=${body}" --jq ".html_url // empty" || __exit=$? if [[ ${__exit} -ne 0 ]]; then - echo "Error: comment_edit: ${method} ${endpoint} failed: ${__err:-${__out:-gh exited with status ${__exit} and no output}}" + echo "Error: comment_edit: ${method} ${endpoint} failed: ${__out}" return "${__exit}" fi - if [[ -z "${__out}" || "${__out}" == "null" ]]; then + if [[ -z "${__out}" ]]; then echo "Error: comment_edit: GitHub returned no URL for the edited comment" return 1 fi diff --git a/plugins/github-mcp/mcp-server-gh/tools-write.json b/plugins/github-mcp/mcp-server-gh/tools-write.json index 817b830..94d4080 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-write.json +++ b/plugins/github-mcp/mcp-server-gh/tools-write.json @@ -757,7 +757,7 @@ }, { "name": "comment_edit", - "description": "Replace the body of an issue or pull request comment, an inline comment on a diff (including one in your own unsubmitted draft), or a summary of a submitted verdict. The new body replaces the whole text. Accepted url forms, with or without a leading https://github.com/: OWNER/REPO/issues/N#issuecomment-ID, OWNER/REPO/pull/N#issuecomment-ID, OWNER/REPO/pull/N#discussion_rID, OWNER/REPO/pull/N/files#rID (or /changes#rID), OWNER/REPO/pull/N#pullrequestreview-ID. Any other url is rejected before the request. Returns the html_url.", + "description": "Replace the body of an issue or pull request comment, an inline comment on a diff (including one in your own unsubmitted draft), or a summary of a submitted verdict. The new body replaces the whole text. Accepted url forms, with no prefix or with a leading https://github.com/, http://github.com/, or https://www.github.com/: OWNER/REPO/issues/N#issuecomment-ID, OWNER/REPO/pull/N#issuecomment-ID, OWNER/REPO/pull/N#discussion_rID, OWNER/REPO/pull/N/files#rID (or /changes#rID), OWNER/REPO/pull/N#pullrequestreview-ID. Any other url, including any other host, is rejected before the request. Returns the edited comment's URL.", "inputSchema": { "type": "object", "required": ["url", "body"], From e8c3bda0cbbd67858cb31c79f024cd33e617775e Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Tue, 6 Oct 2026 19:34:03 +0200 Subject: [PATCH 4/5] fix(github-mcp): refuse other authors' comments unless allow_other_author is set With a maintainer token, `comment_edit` edited any comment on the issue or PR named in the URL, so a wrong URL silently rewrote a contributor's text under their name. The tool now compares the comment's author with the caller's login from GraphQL `viewer { login }` and refuses a mismatch unless the new boolean `allow_other_author` is true, which keeps moderation possible on request. The review summary is now read before its `PUT` for the same check, and a comment in the caller's own pending review needs no check. A write that GitHub accepted but answered without a URL was reported as a failed edit, which invites a retry of an edit that already landed. It stays an error but now says the edit was sent. Links copied from GitHub now work. A query string such as `?notification_referrer_id=` is ignored, and inline comments are accepted on `pull/N/files/..` and `pull/N/commits/`. The README, CHANGELOG and SessionStart wording no longer promise every comment kind. Commit and Discussions comments are not supported. The two inline anchor branches are merged, and a test that could not fail is removed. Co-Authored-By: Claude --- plugin-tests/github-mcp/tool_schemas.bats | 9 + .../github-mcp/write_tools_review.bats | 262 +++++++++++++++--- plugins/github-mcp/CHANGELOG.md | 42 +-- plugins/github-mcp/README.md | 2 +- plugins/github-mcp/REFERENCE.md | 17 +- .../hooks/prompts/write-operations-enabled.md | 2 +- .../mcp-server-gh/lib/review_write.sh | 122 +++++--- .../github-mcp/mcp-server-gh/tools-write.json | 7 +- 8 files changed, 367 insertions(+), 96 deletions(-) diff --git a/plugin-tests/github-mcp/tool_schemas.bats b/plugin-tests/github-mcp/tool_schemas.bats index 0ed76c2..ee867b8 100644 --- a/plugin-tests/github-mcp/tool_schemas.bats +++ b/plugin-tests/github-mcp/tool_schemas.bats @@ -168,6 +168,15 @@ load_validator_for() { assert_output --partial "Unknown parameter(s): fallback" } +@test "comment_edit refuses an allow_other_author that is not a boolean" { + load_validator_for "$WRITE_TOOLS" + + run validate_tool_arguments "comment_edit" '{"url": "o/r/issues/1#issuecomment-2", "body": "x", "allow_other_author": "true"}' + + assert_failure + assert_output --partial "allow_other_author expected boolean, got string" +} + @test "a paging limit sent as a string is refused, naming the parameter" { # The counterpart to the identifier union: limit and max_lines are # integer-only on purpose, so the string form must fail rather than reach diff --git a/plugin-tests/github-mcp/write_tools_review.bats b/plugin-tests/github-mcp/write_tools_review.bats index a9a9588..57e2b95 100644 --- a/plugin-tests/github-mcp/write_tools_review.bats +++ b/plugin-tests/github-mcp/write_tools_review.bats @@ -41,8 +41,10 @@ setup() { # its own stdout, stderr, and exit code: GH_STUB__OUTPUT/_STDERR/_EXIT. # A page fetch of a pending review's comments is keyed by the cursor it # sends, so each page has its own canned answer: GH_STUB_PAGE__*. - local kind="" get_re='^api repos/[^ ]+/comments/[0-9]+$' - if [[ "$*" == *"reviews(states: [PENDING]"* ]]; then + local kind="" get_re='^api repos/[^ ]+/(comments|reviews)/[0-9]+$' + if [[ "$*" == *"viewer { login }"* ]]; then + kind=VIEWER + elif [[ "$*" == *"reviews(states: [PENDING]"* ]]; then kind=LOOKUP elif [[ "$*" == *'node(id: $reviewId)'* ]]; then local arg cursor="" @@ -77,11 +79,12 @@ setup() { } reset_gh_stub local kind - for kind in GET WRITE LOOKUP MUTATION; do + for kind in GET WRITE LOOKUP MUTATION VIEWER; do printf -v "GH_STUB_${kind}_OUTPUT" '%s' "" printf -v "GH_STUB_${kind}_STDERR" '%s' "" printf -v "GH_STUB_${kind}_EXIT" '%s' 0 done + GH_STUB_VIEWER_OUTPUT="${VIEWER_ME}" GH_STUB_HEAD_SHA="0123456789abcdef0123456789abcdef01234567" } @@ -318,15 +321,23 @@ PENDING_PAGED='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"has PAGE_MORE='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_b","fullDatabaseId":"2"}],"pageInfo":{"hasNextPage":true,"endCursor":"CUR2"}}}}}' PAGE_LAST_MATCH='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_node5551","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false,"endCursor":"CURLAST"}}}}}' PAGE_LAST_NO_MATCH='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_c","fullDatabaseId":"3"}],"pageInfo":{"hasNextPage":false,"endCursor":"CURLAST"}}}}}' +VIEWER_ME='{"data":{"viewer":{"login":"me"}}}' +VIEWER_CALL="api graphql -f query=query { viewer { login } }" MUTATION_OK='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":"https://github.com/shopware/shopware/pull/100#discussion_r5551"}}}}' -# The GET answer for a comment that belongs to issue or PR N. +# The GET answer for a comment that belongs to issue or PR N, written by $2 +# (default: the caller, "me"). stub_get_issue() { - GH_STUB_GET_OUTPUT=$(jq -cn --arg u "https://api.github.com/repos/shopware/shopware/issues/$1" '{id: 1, issue_url: $u}') + GH_STUB_GET_OUTPUT=$(jq -cn --arg u "https://api.github.com/repos/shopware/shopware/issues/$1" --arg l "${2:-me}" '{id: 1, issue_url: $u, user: {login: $l}}') } stub_get_pull() { - GH_STUB_GET_OUTPUT=$(jq -cn --arg u "https://api.github.com/repos/shopware/shopware/pulls/$1" '{id: 1, pull_request_url: $u}') + GH_STUB_GET_OUTPUT=$(jq -cn --arg u "https://api.github.com/repos/shopware/shopware/pulls/$1" --arg l "${2:-me}" '{id: 1, pull_request_url: $u, user: {login: $l}}') +} + +# The viewer query was not sent. +assert_no_viewer_query() { + ! grep -qF -- "viewer { login }" "${GH_ARGS_FILE}" || fail "Expected no viewer query, got: $(cat "${GH_ARGS_FILE}")" } # gh must not have run: the rejection happens before any API call. @@ -390,8 +401,9 @@ assert_no_mutation() { ! grep -q -- "updatePullRequestReviewComment" "${GH_ARGS_FILE}" || fail "Expected no mutation, got: $(cat "${GH_ARGS_FILE}")" } +# No GraphQL call other than the viewer login query: no pending-review lookup or mutation. assert_no_graphql_call() { - ! grep -q -- "graphql" "${GH_ARGS_FILE}" || fail "Expected no GraphQL call, got: $(cat "${GH_ARGS_FILE}")" + ! grep -- "graphql" "${GH_ARGS_FILE}" | grep -qvF -- "viewer { login }" || fail "Expected no GraphQL call, got: $(cat "${GH_ARGS_FILE}")" } # A failed call reports through an error message that starts with "Error:". @@ -407,9 +419,10 @@ assert_error_output() { run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "New text"}' assert_success assert_output "https://github.com/shopware/shopware/issues/7#issuecomment-9001" - assert_gh_call_count 2 + assert_gh_call_count 3 assert_gh_call_equal 1 "api repos/shopware/shopware/issues/comments/9001" - assert_gh_call_equal 2 "api repos/shopware/shopware/issues/comments/9001 -X PATCH -f body=New text --jq .html_url // empty" + assert_gh_call_equal 2 "${VIEWER_CALL}" + assert_gh_call_equal 3 "api repos/shopware/shopware/issues/comments/9001 -X PATCH -f body=New text --jq .html_url // empty" } @test "comment_edit patches a PR conversation comment through the issues comments endpoint" { @@ -418,7 +431,7 @@ assert_error_output() { run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#issuecomment-9002", "body": "New text"}' assert_success assert_gh_call_equal 1 "api repos/shopware/shopware/issues/comments/9002" - assert_gh_call_equal 2 "api repos/shopware/shopware/issues/comments/9002 -X PATCH -f body=New text --jq .html_url // empty" + assert_gh_call_equal 3 "api repos/shopware/shopware/issues/comments/9002 -X PATCH -f body=New text --jq .html_url // empty" } @test "comment_edit refuses to edit a conversation comment that belongs to another issue or PR" { @@ -500,9 +513,9 @@ assert_error_output() { run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "New text"}' assert_success assert_output "https://example/c" - assert_gh_call_count 2 + assert_gh_call_count 3 assert_gh_call_equal 1 "api repos/shopware/shopware/pulls/comments/5551" - assert_gh_call_equal 2 "api repos/shopware/shopware/pulls/comments/5551 -X PATCH -f body=New text --jq .html_url // empty" + assert_gh_call_equal 3 "api repos/shopware/shopware/pulls/comments/5551 -X PATCH -f body=New text --jq .html_url // empty" assert_no_graphql_call } @@ -545,7 +558,7 @@ assert_error_output() { assert_failure assert_error_output assert_output --partial "PATCH repos/shopware/shopware/pulls/comments/5551 failed" - assert_gh_call_count 2 + assert_gh_call_count 3 assert_no_graphql_call } @@ -896,8 +909,7 @@ assert_error_output() { run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_failure assert_error_output - assert_output --partial "no URL for the edited comment" - assert_output --partial "GET repos/shopware/shopware/pulls/comments/5551 returned 404 (not found, or no access to the repository); pending-review comment edit failed" + assert_output --partial "GET repos/shopware/shopware/pulls/comments/5551 returned 404 (not found, or no access to the repository); the pending-review edit was sent and GitHub reported success but returned no URL, so the comment may already hold the new body" } @test "comment_edit fails when the mutation response has a wrongly typed data path, with no jq diagnostic" { @@ -954,11 +966,14 @@ assert_error_output() { # ---- review summary body ------------------------------------------------------ -@test "comment_edit puts a review body with PUT on the PR's reviews endpoint without reading it first" { +@test "comment_edit reads a review summary, then puts the body on the PR's reviews endpoint" { + GH_STUB_GET_OUTPUT='{"id":777,"user":{"login":"me"}}' GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-777", "body": "New summary"}' assert_success - assert_gh_args_equal "api repos/shopware/shopware/pulls/100/reviews/777 -X PUT -f body=New summary --jq .html_url // empty" + assert_gh_call_count 3 + assert_gh_call_equal 1 "api repos/shopware/shopware/pulls/100/reviews/777" + assert_gh_call_equal 3 "api repos/shopware/shopware/pulls/100/reviews/777 -X PUT -f body=New summary --jq .html_url // empty" } # ---- stderr warnings on successful calls ------------------------------------- @@ -974,6 +989,7 @@ assert_error_output() { } @test "comment_edit returns the clean URL when a successful review PUT prints a warning on stderr" { + GH_STUB_GET_OUTPUT='{"id":777,"user":{"login":"me"}}' GH_STUB_WRITE_STDERR="${WARNING_STDERR}" GH_STUB_WRITE_OUTPUT="https://example/review" run --separate-stderr tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-777", "body": "x"}' @@ -993,15 +1009,154 @@ assert_error_output() { assert_output "https://github.com/shopware/shopware/pull/100#discussion_r5551" } +# ---- author guard ------------------------------------------------------------- + +@test "comment_edit edits a comment written by the caller, comparing logins without regard to case" { + stub_get_issue 7 "ME" + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + assert_success + assert_output "https://example/c" + assert_gh_call_equal 2 "${VIEWER_CALL}" +} + +@test "comment_edit refuses another author's conversation comment by default, naming both logins, with no write" { + stub_get_issue 7 "octocat" + GH_STUB_WRITE_OUTPUT="https://example/c" + local json + for json in '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' \ + '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x", "allow_other_author": false}'; do + : > "${GH_ARGS_FILE}" + run tool_comment_edit "${json}" + assert_failure + assert_error_output + assert_output --partial "octocat" + assert_output --partial "(me)" + assert_output --partial "allow_other_author: true" + assert_gh_call_count 2 + assert_no_rest_write + done +} + +@test "comment_edit refuses another author's inline comment by default, with no write" { + stub_get_pull 100 "octocat" + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "octocat" + assert_no_rest_write +} + +@test "comment_edit refuses another author's review summary by default, with no PUT" { + GH_STUB_GET_OUTPUT='{"id":777,"user":{"login":"octocat"}}' + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-777", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "octocat" + assert_output --partial "allow_other_author: true" + assert_gh_call_equal 1 "api repos/shopware/shopware/pulls/100/reviews/777" + assert_no_rest_write +} + +@test "comment_edit edits another author's comment when allow_other_author is true, without asking who the caller is" { + stub_get_issue 7 "octocat" + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x", "allow_other_author": true}' + assert_success + assert_output "https://example/c" + assert_gh_call_count 2 + assert_no_viewer_query +} + +@test "comment_edit edits another author's review summary when allow_other_author is true" { + GH_STUB_GET_OUTPUT='{"id":777,"user":{"login":"octocat"}}' + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-777", "body": "x", "allow_other_author": true}' + assert_success + assert_gh_call_count 2 + assert_gh_call_equal 2 "api repos/shopware/shopware/pulls/100/reviews/777 -X PUT -f body=x --jq .html_url // empty" + assert_no_viewer_query +} + +@test "comment_edit fails when the review summary cannot be read, with no PUT" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-777", "body": "x", "allow_other_author": true}' + assert_failure + assert_error_output + assert_output --partial "GET repos/shopware/shopware/pulls/100/reviews/777 failed" + assert_gh_call_count 1 + assert_no_rest_write +} + +@test "comment_edit fails with no write when the caller's login cannot be read" { + stub_get_issue 7 + GH_STUB_VIEWER_EXIT=1 + GH_STUB_VIEWER_STDERR="gh: Bad credentials (HTTP 401)" + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "Bad credentials" + assert_no_rest_write +} + +@test "comment_edit fails with no write when the login query answers with GraphQL errors" { + stub_get_issue 7 + GH_STUB_VIEWER_OUTPUT='{"errors":[{"message":"Resource not accessible"}]}' + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "Resource not accessible" + assert_no_rest_write +} + +@test "comment_edit fails with no write when the login query returns no login" { + stub_get_issue 7 + GH_STUB_VIEWER_OUTPUT='{"data":{"viewer":{"login":null}}}' + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + assert_failure + assert_error_output + assert_no_rest_write +} + +@test "comment_edit fails with no write when the comment names no author, unless allow_other_author is true" { + GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/shopware/shopware/issues/7","user":null}' + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "allow_other_author: true" + assert_no_rest_write + + : > "${GH_ARGS_FILE}" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x", "allow_other_author": true}' + assert_success +} + +@test "comment_edit does not ask who the caller is for a comment in the caller's own pending review" { + GH_STUB_GET_EXIT=1 + GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" + GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" + GH_STUB_MUTATION_OUTPUT="${MUTATION_OK}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + assert_success + assert_no_viewer_query +} + # ---- URL handling ----------------------------------------------------------------- @test "comment_edit takes the repository from the URL, not from the default repo" { - GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/other-org/other.repo/issues/7"}' + GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/other-org/other.repo/issues/7","user":{"login":"me"}}' GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "other-org/other.repo/issues/7#issuecomment-1", "body": "x"}' assert_success assert_gh_call_equal 1 "api repos/other-org/other.repo/issues/comments/1" - assert_gh_call_equal 2 "api repos/other-org/other.repo/issues/comments/1 -X PATCH -f body=x --jq .html_url // empty" + assert_gh_call_equal 3 "api repos/other-org/other.repo/issues/comments/1 -X PATCH -f body=x --jq .html_url // empty" } @test "comment_edit strips the http and www.github.com prefixes" { @@ -1016,14 +1171,65 @@ assert_error_output() { done } +@test "comment_edit accepts a query string before the anchor and the commit and range forms of an inline anchor" { + local sha40="0123456789abcdef0123456789abcdef01234567" + GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/shopware/shopware/issues/100","pull_request_url":"https://api.github.com/repos/shopware/shopware/pulls/100","user":{"login":"me"}}' + GH_STUB_WRITE_OUTPUT="https://example/c" + local row u endpoint + for row in \ + "shopware/shopware/issues/100?notification_referrer_id=NT_x#issuecomment-3|repos/shopware/shopware/issues/comments/3" \ + "https://github.com/shopware/shopware/pull/100?notification_referrer_id=NT_x#issuecomment-3|repos/shopware/shopware/issues/comments/3" \ + "shopware/shopware/pull/100?notification_referrer_id=NT_x#discussion_r5551|repos/shopware/shopware/pulls/comments/5551" \ + "shopware/shopware/pull/100/files?w=1#r5552|repos/shopware/shopware/pulls/comments/5552" \ + "shopware/shopware/pull/100/files/${sha40}..${sha40}#r5553|repos/shopware/shopware/pulls/comments/5553" \ + "shopware/shopware/pull/100/files/abc1234..def5678#r5554|repos/shopware/shopware/pulls/comments/5554" \ + "shopware/shopware/pull/100/commits/${sha40}#r5555|repos/shopware/shopware/pulls/comments/5555" \ + "shopware/shopware/pull/100/commits/abc1234#r5556|repos/shopware/shopware/pulls/comments/5556" \ + "shopware/shopware/pull/100/commits/${sha40}?notification_referrer_id=NT_x#r5557|repos/shopware/shopware/pulls/comments/5557" \ + "shopware/shopware/pull/100?notification_referrer_id=NT_x#pullrequestreview-777|repos/shopware/shopware/pulls/100/reviews/777"; do + u="${row%%|*}" + endpoint="${row##*|}" + : > "${GH_ARGS_FILE}" + rm -f "${BATS_TEST_TMPDIR}"/gh_argv.* + run tool_comment_edit "{\"url\": \"${u}\", \"body\": \"x\"}" + [[ "${status}" -eq 0 ]] || fail "Expected '${u}' to be accepted, got: ${output}" + assert_gh_call_equal 1 "api ${endpoint}" + assert_gh_call_contains_all 3 "api ${endpoint} -X " + done +} + +@test "comment_edit rejects a malformed sha segment, a misplaced query string, and a sha form on an issues URL before any gh call" { + local sha41="0123456789abcdef0123456789abcdef012345678" + local u + for u in "shopware/shopware/pull/100/commits/abc123#r5" \ + "shopware/shopware/pull/100/commits/xyz1234#r5" \ + "shopware/shopware/pull/100/commits/${sha41}#r5" \ + "shopware/shopware/pull/100/commits/abc1234..def5678#r5" \ + "shopware/shopware/pull/100/commits/abc1234/files#r5" \ + "shopware/shopware/pull/100/files/abc1234#r5" \ + "shopware/shopware/pull/100/files/abc1234..#r5" \ + "shopware/shopware/pull/100/files/abc1234..def567#r5" \ + "shopware/shopware/pull/100/files/abc1234...def5678#r5" \ + "shopware/shopware/pull/100/files/${sha41}..abc1234#r5" \ + "shopware/shopware/pull/100/commits/abc1234#issuecomment-5" \ + "shopware/shopware/issues/100/commits/abc1234#r5" \ + "shopware/shopware/pull/100/commits/abc1234#r5?x=1" \ + "shopware/shopware/pull/100?x=1"; do + run tool_comment_edit "{\"url\": \"${u}\", \"body\": \"x\"}" + [[ "${status}" -ne 0 ]] || fail "Expected '${u}' to be rejected" + [[ "${output}" == "Error: url must be "* ]] || fail "Expected the form error for '${u}', got: ${output}" + done + assert_gh_not_called +} + @test "comment_edit accepts an owner with an underscore and reaches gh" { - GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/mona_octocorp/repo/issues/7"}' + GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/mona_octocorp/repo/issues/7","user":{"login":"me"}}' GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "mona_octocorp/repo/issues/7#issuecomment-1", "body": "x"}' assert_success assert_output "https://example/c" assert_gh_call_equal 1 "api repos/mona_octocorp/repo/issues/comments/1" - assert_gh_call_equal 2 "api repos/mona_octocorp/repo/issues/comments/1 -X PATCH -f body=x --jq .html_url // empty" + assert_gh_call_equal 3 "api repos/mona_octocorp/repo/issues/comments/1 -X PATCH -f body=x --jq .html_url // empty" } @test "comment_edit rejects an issue or PR number with a leading zero before any gh call" { @@ -1208,7 +1414,7 @@ assert_error_output() { run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' assert_failure assert_error_output - assert_output --partial "no URL for the edited comment" + assert_output --partial "was sent and GitHub reported success but returned no URL, so the comment may already hold the new body" } @test "comment_edit fails when the PATCH response has no html_url field" { @@ -1218,7 +1424,7 @@ assert_error_output() { run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "x"}' assert_failure assert_error_output - assert_output --partial "no URL for the edited comment" + assert_output --partial "was sent and GitHub reported success but returned no URL, so the comment may already hold the new body" refute_output --partial "null" } @@ -1255,16 +1461,6 @@ assert_one_argv_equal() { TRICKY_BODY=$'-leading dash, two words a=b @file.txt "double" \'single\'\nsecond line' -@test "comment_edit passes a body with special characters as one -f argument" { - stub_get_issue 7 - GH_STUB_WRITE_OUTPUT="https://example/c" - # shellcheck disable=SC2016 # the body's $(x) must stay literal - run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-3", "body": "a=b \"quoted\" $(x)"}' - assert_success - # shellcheck disable=SC2016 - assert_gh_last_args_equal 'api repos/shopware/shopware/issues/comments/3 -X PATCH -f body=a=b "quoted" $(x) --jq .html_url // empty' -} - @test "comment_edit passes a body with a newline, spaces, =, @, quotes, and a leading dash as exactly one REST argument" { stub_get_issue 7 GH_STUB_WRITE_OUTPUT="https://example/c" diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index 7d87419..a31232b 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -9,22 +9,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `comment_edit` write tool. Replaces the body of one existing comment, whatever its kind, from the - comment's URL: `issues/N#issuecomment-ID` and `pull/N#issuecomment-ID` (conversation comments), - `pull/N#discussion_rID` and `pull/N/files#rID` or `pull/N/changes#rID` (inline review comments), - and `pull/N#pullrequestreview-ID` (a review's summary body). The URL may carry a leading - `https://github.com/`; a URL on another host, without an anchor, with an unknown anchor, with a - non-numeric number or ID, or with a number or ID that starts with `0` is rejected before any `gh` - call. The owner may contain `_`, as an Enterprise Managed User account name does. A conversation or - inline comment is read first and edited only when it belongs to the issue or PR `N` in the URL; a - comment of another issue or PR is an error and nothing is edited. When the read of an inline - comment answers 404, the comment may sit in the caller's own unsubmitted (pending) review: the tool - looks the review up through GraphQL, matching `fullDatabaseId` and reading further pages of a - review with more than 100 comments, and edits a matching comment with the - `updatePullRequestReviewComment` mutation. When the lookup fails or finds no match, the error - states the 404 and the lookup's result. Every failure is returned as an error that starts with - `Error:`, and a warning that `gh` writes to stderr on a successful call does not fail the edit. It - returns the updated comment's `html_url`, or the `url` for a comment in a pending review. +- `comment_edit` write tool. Replaces the body of one existing comment from the comment's URL. It + edits four kinds: an issue or PR conversation comment (`issues/N#issuecomment-ID`, + `pull/N#issuecomment-ID`), an inline review comment (`pull/N#discussion_rID`, or an `#rID` anchor on + `pull/N/files`, `pull/N/changes`, `pull/N/files/SHA..SHA`, or `pull/N/commits/SHA`; it may sit in + your own pending review), and a review's summary body (`pull/N#pullrequestreview-ID`). Commit + comments and Discussions comments are not supported. The URL may carry a leading + `https://github.com/` and a `?query` before the `#`; a URL on another host, without an anchor, with + an unknown anchor, with a non-numeric number or ID, with a malformed SHA, or with a number or ID + that starts with `0` is rejected before any `gh` call. The owner may contain `_`, as an Enterprise + Managed User account name does. +- `comment_edit` reads the comment before editing it. A conversation or inline comment is edited only + when it belongs to the issue or PR `N` in the URL; a comment of another issue or PR is an error and + nothing is edited. A comment or review summary written by someone else is refused as well, with an + error naming both logins, unless `allow_other_author` is `true` (for moderation). The tool reads the + authenticated login with one GraphQL query, and skips that query when `allow_other_author` is + `true`. A comment in your own pending review needs neither check. +- `comment_edit` handles a comment in your own unsubmitted (pending) review: when the read of an + inline comment answers 404, the tool looks the review up through GraphQL, matching + `fullDatabaseId` and reading further pages of a review with more than 100 comments, and edits a + matching comment with the `updatePullRequestReviewComment` mutation. When the lookup fails or finds + no match, the error states the 404 and the lookup's result. +- `comment_edit` returns every failure as an error that starts with `Error:`, and a warning that + `gh` writes to stderr on a successful call does not fail the edit. It returns the updated + comment's `html_url`, or the `url` for a comment in a pending review. When GitHub reports success + but returns no URL, the call is still an error, and its message says the edit was sent and the + comment may already hold the new body. - `check-api-tools.sh` redirects `PATCH repos/O/R/issues/comments/ID`, `PATCH repos/O/R/pulls/comments/ID`, and `PUT repos/O/R/pulls/N/reviews/ID` on the `api` write tool to `comment_edit`, under `block_api_tool_write`. The message for a conversation comment points to diff --git a/plugins/github-mcp/README.md b/plugins/github-mcp/README.md index fd917e7..0ada3c7 100644 --- a/plugins/github-mcp/README.md +++ b/plugins/github-mcp/README.md @@ -22,7 +22,7 @@ GitHub CLI tools via MCP (Model Context Protocol). Wraps the `gh` CLI for pull r ### Write Server (gh-tooling-write) - **PR lifecycle** via `pr_create`, `pr_edit`, `pr_ready`, `pr_merge`, `pr_close`, `pr_reopen` -- **Reviews** via `pr_review_submit`, `pr_comment`, `pr_review_reply`, `comment_edit` (edits any existing comment by its URL) +- **Reviews** via `pr_review_submit`, `pr_comment`, `pr_review_reply`, `comment_edit` (edits an issue or PR conversation comment, an inline review comment, or a review summary by its URL) - **Issue lifecycle** via `issue_create`, `issue_edit`, `issue_close`, `issue_reopen`, `issue_comment` - **Labels** via `label_add`, `label_remove` - **Assignees** via `assignee_add`, `assignee_remove` diff --git a/plugins/github-mcp/REFERENCE.md b/plugins/github-mcp/REFERENCE.md index 9b9adcc..f213729 100644 --- a/plugins/github-mcp/REFERENCE.md +++ b/plugins/github-mcp/REFERENCE.md @@ -735,34 +735,37 @@ Use gh-tooling-write pr_review_reply with number 14642 and comment_id 1234567 an #### `comment_edit` -Replace the body of one existing comment, whatever its kind: an issue or PR conversation comment, an inline review comment, or a review's summary body. The kind comes from the anchor of `url`, so pass the URL exactly as `issue_comment` or `pr_comment` returned it, as the `url` of a comment from `issue_view` or `pr_view` with `fields=comments`, or as the `html_url` from `pr_comments`, `pr_reviews`, or `pr_review_reply`. The body replaces the whole comment text; send the full new text, not a diff. Returns the updated comment's URL. +Replace the body of one existing comment. Four kinds are supported: an issue or PR conversation comment, an inline review comment (including one in your own pending review), and a review's summary body. Commit comments and Discussions comments are not supported. The kind comes from the anchor of `url`, so pass the URL exactly as `issue_comment` or `pr_comment` returned it, as the `url` of a comment from `issue_view` or `pr_view` with `fields=comments`, or as the `html_url` from `pr_comments`, `pr_reviews`, or `pr_review_reply`. The body replaces the whole comment text; send the full new text, not a diff. Returns the updated comment's URL. ``` Use gh-tooling-write comment_edit with url "https://github.com/shopware/shopware/pull/14642#issuecomment-2001" and body "CI is green, ready to merge" Use gh-tooling-write comment_edit with url "shopware/shopware/pull/14642#discussion_r1234567" and body "Fixed in b8f9a8c." ``` -Accepted `url` forms, each with or without a leading `https://github.com/` (`http://github.com/` and `https://www.github.com/` also work; any other scheme or host is rejected). `OWNER` is letters, digits, `-`, and `_` (an Enterprise Managed User account such as `mona_octocorp` has one). `N` and `ID` are digits only and do not start with `0`: +Accepted `url` forms, each with or without a leading `https://github.com/` (`http://github.com/` and `https://www.github.com/` also work; any other scheme or host is rejected). A `?query` before the `#`, such as `?notification_referrer_id=NT_x`, is ignored. `OWNER` is letters, digits, `-`, and `_` (an Enterprise Managed User account such as `mona_octocorp` has one). `N` and `ID` are digits only and do not start with `0`. `SHA` is 7 to 40 hex characters: | `url` form | Edits | Request | |--------------------------------------------------------|---------------------------------|-------------------------------------------------| | `OWNER/REPO/issues/N#issuecomment-ID` | Issue conversation comment | `PATCH repos/OWNER/REPO/issues/comments/ID` | | `OWNER/REPO/pull/N#issuecomment-ID` | PR conversation comment | `PATCH repos/OWNER/REPO/issues/comments/ID` | | `OWNER/REPO/pull/N#discussion_rID` | Inline review comment | `PATCH repos/OWNER/REPO/pulls/comments/ID` (see below) | -| `OWNER/REPO/pull/N/files#rID` or `.../changes#rID` | Inline review comment | `PATCH repos/OWNER/REPO/pulls/comments/ID` (see below) | +| `OWNER/REPO/pull/N/files#rID`, `.../changes#rID`, `.../files/SHA..SHA#rID`, or `.../commits/SHA#rID` | Inline review comment | `PATCH repos/OWNER/REPO/pulls/comments/ID` (see below) | | `OWNER/REPO/pull/N#pullrequestreview-ID` | Review summary body | `PUT repos/OWNER/REPO/pulls/N/reviews/ID` | -Before editing a conversation comment or an inline review comment, the tool reads it (`GET`) and checks that it belongs to the issue or PR `N` in the URL: the comment's `issue_url` must end in `/issues/N` (PR conversation comments also use `/issues/N`), or its `pull_request_url` in `/pulls/N`. A comment of another issue or PR is an error naming both numbers, and nothing is edited. A failed read, including a 404 on a conversation comment, is an error with gh's message and no edit. +Before editing, the tool reads the comment (`GET`). For a conversation comment or an inline review comment it checks that the comment belongs to the issue or PR `N` in the URL: the comment's `issue_url` must end in `/issues/N` (PR conversation comments also use `/issues/N`), or its `pull_request_url` in `/pulls/N`. A comment of another issue or PR is an error naming both numbers, and nothing is edited. A review summary is read from `GET repos/OWNER/REPO/pulls/N/reviews/ID`, which carries the PR number in its path. A failed read, including a 404 on a conversation comment or a review summary, is an error with gh's message and no edit. -For the two inline review comment forms, a 404 on that read means the comment is not a submitted one, or the token has no access to the repository. The comment can still be in your own unsubmitted (pending) review, which the REST endpoint does not return. The tool then looks up your pending review on PR `N` through GraphQL (among up to 100 pending reviews, only the one you wrote) and matches the comment's `fullDatabaseId`. When your review has more than 100 comments, the tool reads the further pages until it finds the comment or reaches the last page. It edits a match with the `updatePullRequestReviewComment` mutation and returns the comment's `url`. When the lookup finds no match or fails, the error states both facts: `Error: comment_edit: GET repos/OWNER/REPO/pulls/comments/ID returned 404 (not found, or no access to the repository); pending-review lookup on PR N: `. A failed page is a lookup error. So is a PR with more than 100 pending reviews none of which is yours. A mutation that fails or returns no URL is an error that starts with the same 404 statement, followed by `pending-review comment edit failed: ...`; pages that never advance (GitHub returns a cursor already used in this lookup) are a lookup error. A read that fails with anything other than a 404 is an error too. A failed read, lookup page, or mutation makes no further call. The review summary form sends its `PUT` without a read. +For the two inline review comment forms, a 404 on that read means the comment is not a submitted one, or the token has no access to the repository. The comment can still be in your own unsubmitted (pending) review, which the REST endpoint does not return. The tool then looks up your pending review on PR `N` through GraphQL (among up to 100 pending reviews, only the one you wrote) and matches the comment's `fullDatabaseId`. When your review has more than 100 comments, the tool reads the further pages until it finds the comment or reaches the last page. It edits a match with the `updatePullRequestReviewComment` mutation and returns the comment's `url`. When the lookup finds no match or fails, the error states both facts: `Error: comment_edit: GET repos/OWNER/REPO/pulls/comments/ID returned 404 (not found, or no access to the repository); pending-review lookup on PR N: `. A failed page is a lookup error. So is a PR with more than 100 pending reviews none of which is yours. A mutation that fails is an error that starts with the same 404 statement, followed by `pending-review comment edit failed: ...`; a mutation that succeeds without a URL is the same 404 statement followed by the sent-but-no-URL message described below; pages that never advance (GitHub returns a cursor already used in this lookup) are a lookup error. A read that fails with anything other than a 404 is an error too. A failed read, lookup page, or mutation makes no further call. -Every failure is an error: the output starts with `Error:` and the call fails. Output that a successful `gh` call writes to stderr, such as a warning, does not fail the edit. A failed `gh` call returns its stderr message in the error. +Author check: unless `allow_other_author` is `true`, the tool then compares the `user.login` of the comment or review summary with the authenticated user's login, case-insensitively. The login comes from one GraphQL `viewer { login }` query per call. A different author is an error that names both logins and says to pass `allow_other_author: true`, and nothing is edited. A failed login query, a response without a login, or a comment without an author login is an error and nothing is edited. With `allow_other_author: true` the tool skips the login query and the comparison. A comment found in your own pending review is not checked, because that lookup only covers your own review and sends no login query. -Any other URL fails before the request: no anchor, an unknown anchor, a non-numeric number or ID, a number or ID with a leading zero, `#discussion_rID`, `#rID`, or `#pullrequestreview-ID` on an `issues/N` URL, `#rID` without `/files` or `/changes`, and extra path segments or a query string. The repository comes from the URL, so the tool takes no `repo` parameter. +Every failure is an error: the output starts with `Error:` and the call fails. When the edit request succeeds but GitHub's response holds no URL, the call is an error too: the message says the edit was sent and GitHub reported success but returned no URL, so the comment may already hold the new body. Output that a successful `gh` call writes to stderr, such as a warning, does not fail the edit. A failed `gh` call returns its stderr message in the error. + +Any other URL fails before the request: no anchor, an unknown anchor, a non-numeric number or ID, a number or ID with a leading zero, `#discussion_rID`, `#rID`, or `#pullrequestreview-ID` on an `issues/N` URL, `#rID` without `/files`, `/changes`, `/files/SHA..SHA`, or `/commits/SHA`, a malformed `SHA` segment, a query string after the `#`, and extra path segments. The repository comes from the URL, so the tool takes no `repo` parameter. **Parameters:** - `url` (string, required): URL of the comment to edit, with its anchor. - `body` (string, required): New comment text. Replaces the whole existing text. +- `allow_other_author` (boolean, optional, default `false`): Editing a comment written by someone else, for example for moderation, requires `true`. ### Issue Write Tools diff --git a/plugins/github-mcp/hooks/prompts/write-operations-enabled.md b/plugins/github-mcp/hooks/prompts/write-operations-enabled.md index 90de414..33424e5 100644 --- a/plugins/github-mcp/hooks/prompts/write-operations-enabled.md +++ b/plugins/github-mcp/hooks/prompts/write-operations-enabled.md @@ -1,6 +1,6 @@ ## Write (gh-tooling-write) PRs: pr_create, pr_edit, pr_ready, pr_merge, pr_close, pr_reopen -Reviews: pr_review_submit, pr_comment, pr_review_reply, comment_edit (replaces the body of one existing issue, PR, review, or inline review comment, found by its url) +Reviews: pr_review_submit, pr_comment, pr_review_reply, comment_edit (replaces the body of one existing issue or PR conversation comment, inline review comment, or review summary, found by its url; another author's comment needs allow_other_author: true) Issues: issue_create, issue_edit, issue_close, issue_reopen, issue_comment Issue type and fields: issue_type_set, issue_field_set (issue_field_set replaces the issue's whole set of field values; read them first with issue_view and with_field_values true) Labels: label_add, label_remove diff --git a/plugins/github-mcp/mcp-server-gh/lib/review_write.sh b/plugins/github-mcp/mcp-server-gh/lib/review_write.sh index 8a8f496..ff977d5 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/review_write.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/review_write.sh @@ -419,17 +419,62 @@ _comment_edit_find_pending() { } ####################################### -# Replace the body of one existing comment, whatever its kind. The kind comes -# from the anchor of the URL; REFERENCE.md lists the accepted URL forms. A URL -# that matches none fails before any gh call. A conversation or inline comment -# is read first and edited only when it belongs to the issue or PR number in -# the URL. An inline comment the REST API reports as 404 may sit in the -# caller's own pending review, so it is looked up there, page by page, and -# edited through GraphQL; when that lookup fails or finds nothing, the error -# states the 404 and the lookup's result. Every other failure ends the call -# without a write. +# Refuse to edit a comment somebody else wrote. The author comes from the +# response of the read that precedes the write; the caller's login comes from +# one GraphQL viewer query. Logins compare case-insensitively. # Arguments: -# $1 JSON arguments: url (required) and body (required). +# $1 response of the GET of the comment or review summary, +# $2 the GET's endpoint, for the message. +# Outputs: +# An "Error: ..." message on stdout when the author cannot be read, the +# login lookup fails, or the logins differ. +# Returns: +# 0 when the caller wrote it; otherwise gh's exit status from the login +# lookup, or 1. +####################################### +_comment_edit_check_author() { + local response="$1" endpoint="$2" + local author viewer_out viewer viewer_exit=0 + + if ! author=$(printf '%s' "${response}" | jq -r '.user.login // empty' 2>/dev/null) || [[ -z "${author}" ]]; then + echo "Error: comment_edit: GET ${endpoint} returned no author login, so it cannot be compared with yours; nothing was edited. Pass allow_other_author: true to edit it anyway: ${response}" + return 1 + fi + + log "INFO" "comment_edit: reading the authenticated login" + _comment_edit_graphql viewer_out gh api graphql -f 'query=query { viewer { login } }' || viewer_exit=$? + if [[ ${viewer_exit} -ne 0 ]]; then + echo "Error: comment_edit: reading your login failed: ${viewer_out}; nothing was edited" + return "${viewer_exit}" + fi + if ! viewer=$(printf '%s' "${viewer_out}" | jq -r '.data.viewer.login // empty' 2>/dev/null) || [[ -z "${viewer}" ]]; then + echo "Error: comment_edit: the response to the login query holds no login; nothing was edited: ${viewer_out}" + return 1 + fi + + if [[ "${author,,}" != "${viewer,,}" ]]; then + echo "Error: comment_edit: the comment was written by ${author}, not by you (${viewer}); nothing was edited. Pass allow_other_author: true to edit it" + return 1 + fi +} + +####################################### +# Replace the body of one existing comment: an issue or PR conversation +# comment, an inline review comment (including one in the caller's own pending +# review), or a review summary. Commit comments and Discussions comments are +# not supported. The kind comes from the anchor of the URL; REFERENCE.md lists +# the accepted URL forms. A URL that matches none fails before any gh call. The +# comment is read first. A conversation or inline comment is edited only when +# it belongs to the issue or PR number in the URL. Unless allow_other_author is +# true, the edit also needs the comment's author to be the caller. An inline +# comment the REST API reports as 404 may sit in the caller's own pending +# review, so it is looked up there, page by page, and edited through GraphQL; +# that review is the caller's by the lookup, so no author check runs. When that +# lookup fails or finds nothing, the error states the 404 and the lookup's +# result. Every other failure ends the call without a write. +# Arguments: +# $1 JSON arguments: url (required), body (required), allow_other_author +# (optional boolean, default false). # Outputs: # The edited comment's URL on success, otherwise an "Error: ..." message. # Returns: @@ -439,9 +484,10 @@ _comment_edit_find_pending() { tool_comment_edit() { local args="$1" - local url body + local url body allow_other_author url=$(echo "${args}" | jq -r '.url // empty') body=$(echo "${args}" | jq -r '.body // empty') + allow_other_author=$(echo "${args}" | jq -r '.allow_other_author // false') if [[ -z "${url}" ]]; then echo "Error: url is required for comment_edit" @@ -467,34 +513,33 @@ tool_comment_edit() { # Owners may contain "_" (Enterprise Managed User accounts). GitHub never # generates a number or ID with a leading zero, so none is accepted. - local form_error="Error: url must be OWNER/REPO/issues/N#issuecomment-ID, OWNER/REPO/pull/N#issuecomment-ID, OWNER/REPO/pull/N#discussion_rID, OWNER/REPO/pull/N/files#rID, OWNER/REPO/pull/N/changes#rID, or OWNER/REPO/pull/N#pullrequestreview-ID (optionally with a leading https://github.com/), got: '${url}'" - local url_re='^([A-Za-z0-9_-]+/[A-Za-z0-9_.-]+)/(issues|pull)/([1-9][0-9]*)(/files|/changes)?#([A-Za-z0-9_-]+)$' + # _gh_validate_repo and _gh_validate_number accept an owner with "." and a + # leading zero or 0, which this tool rejects, so the patterns below stay. + # A "?query" before the "#" is dropped. + local sha='[0-9a-fA-F]{7,40}' + local form_error="Error: url must be OWNER/REPO/issues/N#issuecomment-ID, OWNER/REPO/pull/N#issuecomment-ID, OWNER/REPO/pull/N#discussion_rID, OWNER/REPO/pull/N/files#rID, OWNER/REPO/pull/N/changes#rID, OWNER/REPO/pull/N/files/SHA..SHA#rID, OWNER/REPO/pull/N/commits/SHA#rID, or OWNER/REPO/pull/N#pullrequestreview-ID (SHA is 7 to 40 hex characters; optionally with a leading https://github.com/ and a ?query before the #), got: '${url}'" + local url_re="^([A-Za-z0-9_-]+/[A-Za-z0-9_.-]+)/(issues|pull)/([1-9][0-9]*)(/files|/changes|/files/${sha}\\.\\.${sha}|/commits/${sha})?(\\?[^#]*)?#([A-Za-z0-9_-]+)\$" if [[ ! "${rest}" =~ ${url_re} ]]; then echo "${form_error}" return 1 fi local repo="${BASH_REMATCH[1]}" kind="${BASH_REMATCH[2]}" number="${BASH_REMATCH[3]}" - local suffix="${BASH_REMATCH[4]}" anchor="${BASH_REMATCH[5]}" + local suffix="${BASH_REMATCH[4]}" anchor="${BASH_REMATCH[6]}" if [[ "${repo##*/}" == "." || "${repo##*/}" == ".." || "${repo##*/}" == *.git ]]; then echo "${form_error}" return 1 fi # owner_field names the field of the GET response that holds the issue or - # PR the comment belongs to; owner_path is how that URL must end. + # PR the comment belongs to; owner_path is how that URL must end. A review + # summary has neither: its endpoint already carries the PR number. local endpoint method owner_field="" owner_path="" review_comment_id="" if [[ "${anchor}" =~ ^issuecomment-([1-9][0-9]*)$ && -z "${suffix}" ]]; then endpoint="repos/${repo}/issues/comments/${BASH_REMATCH[1]}" method="PATCH" owner_field="issue_url" owner_path="issues/${number}" - elif [[ "${kind}" == "pull" && -z "${suffix}" && "${anchor}" =~ ^discussion_r([1-9][0-9]*)$ ]]; then - endpoint="repos/${repo}/pulls/comments/${BASH_REMATCH[1]}" - method="PATCH" - owner_field="pull_request_url" - owner_path="pulls/${number}" - review_comment_id="${BASH_REMATCH[1]}" - elif [[ "${kind}" == "pull" && -n "${suffix}" && "${anchor}" =~ ^r([1-9][0-9]*)$ ]]; then + elif [[ "${kind}" == "pull" ]] && { [[ -z "${suffix}" && "${anchor}" =~ ^discussion_r([1-9][0-9]*)$ ]] || [[ -n "${suffix}" && "${anchor}" =~ ^r([1-9][0-9]*)$ ]]; }; then endpoint="repos/${repo}/pulls/comments/${BASH_REMATCH[1]}" method="PATCH" owner_field="pull_request_url" @@ -511,20 +556,20 @@ tool_comment_edit() { local __out __err __exit=0 local not_found_note="" - if [[ -n "${owner_field}" ]]; then - log "INFO" "comment_edit: GET ${endpoint}" - _comment_edit_gh __out __err gh api "${endpoint}" || __exit=$? - if [[ ${__exit} -ne 0 ]]; then - # Only an inline comment can still be edited: REST answers 404 for - # a comment inside the caller's unsubmitted review. - if [[ -n "${review_comment_id}" && "${__err}" == *"(HTTP 404)"* ]]; then - not_found_note="GET ${endpoint} returned 404 (not found, or no access to the repository)" - __exit=0 - else - echo "Error: comment_edit: GET ${endpoint} failed: ${__out}" - return "${__exit}" - fi + log "INFO" "comment_edit: GET ${endpoint}" + _comment_edit_gh __out __err gh api "${endpoint}" || __exit=$? + if [[ ${__exit} -ne 0 ]]; then + # Only an inline comment can still be edited: REST answers 404 for a + # comment inside the caller's unsubmitted review. + if [[ -n "${review_comment_id}" && "${__err}" == *"(HTTP 404)"* ]]; then + not_found_note="GET ${endpoint} returned 404 (not found, or no access to the repository)" + __exit=0 else + echo "Error: comment_edit: GET ${endpoint} failed: ${__out}" + return "${__exit}" + fi + else + if [[ -n "${owner_field}" ]]; then local owner_url if ! owner_url=$(printf '%s' "${__out}" | jq -r --arg field "${owner_field}" '.[$field] // empty' 2>/dev/null) || [[ -z "${owner_url}" ]]; then echo "Error: comment_edit: GET ${endpoint} returned no ${owner_field}: ${__out}" @@ -535,6 +580,9 @@ tool_comment_edit() { return 1 fi fi + if [[ "${allow_other_author}" != "true" ]]; then + _comment_edit_check_author "${__out}" "${endpoint}" || return $? + fi fi if [[ -n "${not_found_note}" ]]; then @@ -559,7 +607,7 @@ tool_comment_edit() { return 1 fi if [[ -z "${mutation_url}" ]]; then - echo "Error: comment_edit: ${not_found_note}; pending-review comment edit failed: GitHub returned no URL for the edited comment: ${mutation}" + echo "Error: comment_edit: ${not_found_note}; the pending-review edit was sent and GitHub reported success but returned no URL, so the comment may already hold the new body: ${mutation}" return 1 fi echo "${mutation_url}" @@ -573,7 +621,7 @@ tool_comment_edit() { return "${__exit}" fi if [[ -z "${__out}" ]]; then - echo "Error: comment_edit: GitHub returned no URL for the edited comment" + echo "Error: comment_edit: ${method} ${endpoint} was sent and GitHub reported success but returned no URL, so the comment may already hold the new body" return 1 fi echo "${__out}" diff --git a/plugins/github-mcp/mcp-server-gh/tools-write.json b/plugins/github-mcp/mcp-server-gh/tools-write.json index 94d4080..898e105 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-write.json +++ b/plugins/github-mcp/mcp-server-gh/tools-write.json @@ -757,7 +757,7 @@ }, { "name": "comment_edit", - "description": "Replace the body of an issue or pull request comment, an inline comment on a diff (including one in your own unsubmitted draft), or a summary of a submitted verdict. The new body replaces the whole text. Accepted url forms, with no prefix or with a leading https://github.com/, http://github.com/, or https://www.github.com/: OWNER/REPO/issues/N#issuecomment-ID, OWNER/REPO/pull/N#issuecomment-ID, OWNER/REPO/pull/N#discussion_rID, OWNER/REPO/pull/N/files#rID (or /changes#rID), OWNER/REPO/pull/N#pullrequestreview-ID. Any other url, including any other host, is rejected before the request. Returns the edited comment's URL.", + "description": "Replace the body of an issue or pull request comment, an inline comment on a diff (including one in your own unsubmitted draft), or a summary of a submitted verdict. The new body replaces the whole text. Accepted url forms, with no prefix or with a leading https://github.com/, http://github.com/, or https://www.github.com/: OWNER/REPO/issues/N#issuecomment-ID, OWNER/REPO/pull/N#issuecomment-ID, OWNER/REPO/pull/N#discussion_rID, OWNER/REPO/pull/N/files#rID (or /changes#rID, /files/SHA..SHA#rID, /commits/SHA#rID), OWNER/REPO/pull/N#pullrequestreview-ID; a ?query before the # is ignored. Any other url, including any other host, is rejected before the request. A comment written by someone else is refused unless allow_other_author is true. Returns the edited comment's URL.", "inputSchema": { "type": "object", "required": ["url", "body"], @@ -769,6 +769,11 @@ "body": { "type": "string", "description": "New comment text. Replaces the whole existing text." + }, + "allow_other_author": { + "type": "boolean", + "description": "Editing a comment written by someone else, for example for moderation, requires true.", + "default": false } }, "additionalProperties": false From 8b40acbcf4883389891c303cf1c3e6bfd92d7570 Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Tue, 6 Oct 2026 23:07:28 +0200 Subject: [PATCH 5/5] fix(github-mcp): ask GitHub who wrote a comment and drop unneeded guards Comparing the comment's REST `user.login` with GraphQL `viewer.login` may disagree for App and Actions tokens, which would refuse a bot's edit of its own comment. `comment_edit` now asks GitHub directly through `viewerDidAuthor` on the comment's node and refuses anything but `true` unless `allow_other_author` is set. Several guards protected against responses GitHub does not send or against URLs it never produces, and each would have failed loudly without them. Cursor cycle detection is replaced by a 50-page limit, the pending-review author filter and the more-than-100-pending-reviews error are gone (whether another user's pending comment may be edited is left to GitHub's mutation), malformed-response branches collapse into fewer errors, and `.git` and leading-zero rejections are dropped. The `.`/`..` repo rejection stays, since `repos/o/../...` would reach a different endpoint. Links copied from GitHub's Files and Changes views of a single commit or a range now work, and the host is matched case-insensitively. `check-gh-tools.sh` now redirects raw `gh api` edits of comments and review summaries to `comment_edit` under `block_api_commands`. Before, these calls were not redirected to `comment_edit`, and one form of the review `PUT` went to `pr_reviews`. The CHANGELOG entries describe what users can do instead of how the tool works inside, REFERENCE.md lost claims the code no longer backs, the plugin-setup CHANGELOG records its new permission pattern, and the URL-form tests are one table. Co-Authored-By: Claude --- plugin-tests/github-mcp/gh_tools.bats | 28 + .../github-mcp/write_tools_review.bats | 634 +++++++----------- plugins/github-mcp/AGENTS.md | 2 +- plugins/github-mcp/CHANGELOG.md | 46 +- plugins/github-mcp/README.md | 3 +- plugins/github-mcp/REFERENCE.md | 15 +- .../hooks/scripts/check-gh-tools.sh | 15 + .../mcp-server-gh/lib/review_write.sh | 291 ++++---- .../github-mcp/mcp-server-gh/tools-write.json | 2 +- plugins/plugin-setup/CHANGELOG.md | 7 + 10 files changed, 439 insertions(+), 604 deletions(-) diff --git a/plugin-tests/github-mcp/gh_tools.bats b/plugin-tests/github-mcp/gh_tools.bats index 9bd189b..107f4fa 100644 --- a/plugin-tests/github-mcp/gh_tools.bats +++ b/plugin-tests/github-mcp/gh_tools.bats @@ -237,6 +237,34 @@ bats_test_function \ "gh api repos/shopware/shopware/pulls/14642/reviews" \ "pr_reviews" +# comment_edit redirects: the method flag before or after the endpoint, and the +# reviews/ID PUT must not fall through to pr_reviews. +gh_api_edit_blocks() { + gh_api_hook_blocks "$1" "comment_edit" + refute_output --partial "pr_reviews" +} + +bats_test_function \ + --description "blocks gh api -X PATCH .../issues/comments/ID → suggests comment_edit" \ + -- gh_api_edit_blocks \ + "gh api -X PATCH repos/shopware/shopware/issues/comments/2001 -f body=x" + +bats_test_function \ + --description "blocks gh api .../pulls/comments/ID --method PATCH → suggests comment_edit" \ + -- gh_api_edit_blocks \ + "gh api repos/shopware/shopware/pulls/comments/1234567 --method PATCH -f body=x" + +bats_test_function \ + --description "blocks gh api -X PUT .../pulls/N/reviews/ID → suggests comment_edit, not pr_reviews" \ + -- gh_api_edit_blocks \ + "gh api -X PUT repos/shopware/shopware/pulls/14642/reviews/777 -f body=x" + +bats_test_function \ + --description "blocks gh api .../pulls/N/reviews/ID -X GET → suggests pr_reviews" \ + -- gh_api_hook_blocks \ + "gh api repos/shopware/shopware/pulls/14642/reviews/777 -X GET" \ + "pr_reviews" + bats_test_function \ --description "blocks gh api .../pulls/N/files → suggests pr_files" \ -- gh_api_hook_blocks \ diff --git a/plugin-tests/github-mcp/write_tools_review.bats b/plugin-tests/github-mcp/write_tools_review.bats index 57e2b95..c5803bc 100644 --- a/plugin-tests/github-mcp/write_tools_review.bats +++ b/plugin-tests/github-mcp/write_tools_review.bats @@ -26,8 +26,9 @@ setup() { call_n=$(find "${BATS_TEST_TMPDIR}" -maxdepth 1 -name 'gh_argv.*' | wc -l | tr -d ' ') printf '%s\0' "$@" > "${BATS_TEST_TMPDIR}/gh_argv.$((call_n + 1))" # Runaway guard: a looping lookup ends here instead of hanging the suite. - if [[ "${call_n}" -ge 20 ]]; then - echo "stub: more than 20 gh calls" >&2 + # The page-limit test makes 51 calls. + if [[ "${call_n}" -ge 60 ]]; then + echo "stub: more than 60 gh calls" >&2 return 99 fi if [[ "$*" == *"--input"* ]]; then @@ -42,10 +43,11 @@ setup() { # A page fetch of a pending review's comments is keyed by the cursor it # sends, so each page has its own canned answer: GH_STUB_PAGE__*. local kind="" get_re='^api repos/[^ ]+/(comments|reviews)/[0-9]+$' - if [[ "$*" == *"viewer { login }"* ]]; then - kind=VIEWER - elif [[ "$*" == *"reviews(states: [PENDING]"* ]]; then + # shellcheck disable=SC2016 # literal GraphQL text ($reviewId), not a shell variable + if [[ "$*" == *"reviews(states: [PENDING]"* ]]; then kind=LOOKUP + elif [[ "$*" == *"... on Comment { viewerDidAuthor }"* ]]; then + kind=AUTHOR elif [[ "$*" == *'node(id: $reviewId)'* ]]; then local arg cursor="" for arg in "$@"; do @@ -79,12 +81,12 @@ setup() { } reset_gh_stub local kind - for kind in GET WRITE LOOKUP MUTATION VIEWER; do + for kind in GET WRITE LOOKUP MUTATION AUTHOR; do printf -v "GH_STUB_${kind}_OUTPUT" '%s' "" printf -v "GH_STUB_${kind}_STDERR" '%s' "" printf -v "GH_STUB_${kind}_EXIT" '%s' 0 done - GH_STUB_VIEWER_OUTPUT="${VIEWER_ME}" + GH_STUB_AUTHOR_OUTPUT="${AUTHOR_YES}" GH_STUB_HEAD_SHA="0123456789abcdef0123456789abcdef01234567" } @@ -314,30 +316,35 @@ NOT_FOUND_STDERR="gh: Not Found (HTTP 404)" SERVER_ERROR_STDERR="gh: Internal Server Error (HTTP 500)" WARNING_STDERR="warning: a new gh release is available" -PENDING_NONE='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[]}}}}}' -PENDING_MATCH='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_other","fullDatabaseId":"4000"},{"id":"PRRC_node5551","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' -PENDING_BIG='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_near","fullDatabaseId":"4195291200"},{"id":"PRRC_big","fullDatabaseId":"4195291201"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' -PENDING_PAGED='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"id":"PRR_mine","viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_a","fullDatabaseId":"1"}],"pageInfo":{"hasNextPage":true,"endCursor":"CUR1"}}}]}}}}}' +PENDING_MATCH='{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"comments":{"nodes":[{"id":"PRRC_other","fullDatabaseId":"4000"},{"id":"PRRC_node5551","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' +PENDING_BIG='{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"comments":{"nodes":[{"id":"PRRC_near","fullDatabaseId":"4195291200"},{"id":"PRRC_big","fullDatabaseId":"4195291201"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' +PENDING_PAGED='{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"id":"PRR_mine","comments":{"nodes":[{"id":"PRRC_a","fullDatabaseId":"1"}],"pageInfo":{"hasNextPage":true,"endCursor":"CUR1"}}}]}}}}}' PAGE_MORE='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_b","fullDatabaseId":"2"}],"pageInfo":{"hasNextPage":true,"endCursor":"CUR2"}}}}}' PAGE_LAST_MATCH='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_node5551","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false,"endCursor":"CURLAST"}}}}}' PAGE_LAST_NO_MATCH='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_c","fullDatabaseId":"3"}],"pageInfo":{"hasNextPage":false,"endCursor":"CURLAST"}}}}}' -VIEWER_ME='{"data":{"viewer":{"login":"me"}}}' -VIEWER_CALL="api graphql -f query=query { viewer { login } }" +AUTHOR_YES='{"data":{"node":{"viewerDidAuthor":true}}}' +AUTHOR_NO='{"data":{"node":{"viewerDidAuthor":false}}}' +# The author query's argv, up to the node ID that follows it. +# shellcheck disable=SC2016 # literal GraphQL text, not a shell variable +AUTHOR_CALL='api graphql -f query=query($id: ID!) { node(id: $id) { ... on Comment { viewerDidAuthor } } } -f id=' +# A text no failure message may repeat: it stands for a comment body. +BODY_MARKER="SECRET-BODY-TEXT" MUTATION_OK='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":"https://github.com/shopware/shopware/pull/100#discussion_r5551"}}}}' # The GET answer for a comment that belongs to issue or PR N, written by $2 -# (default: the caller, "me"). +# (default "me"; the login only names the author in a refusal) and carrying a +# body, so a test can tell whether a failure repeats the comment. stub_get_issue() { - GH_STUB_GET_OUTPUT=$(jq -cn --arg u "https://api.github.com/repos/shopware/shopware/issues/$1" --arg l "${2:-me}" '{id: 1, issue_url: $u, user: {login: $l}}') + GH_STUB_GET_OUTPUT=$(jq -cn --arg u "https://api.github.com/repos/shopware/shopware/issues/$1" --arg l "${2:-me}" --arg b "${BODY_MARKER}" '{id: 1, node_id: "IC_node1", issue_url: $u, user: {login: $l}, body: $b}') } stub_get_pull() { - GH_STUB_GET_OUTPUT=$(jq -cn --arg u "https://api.github.com/repos/shopware/shopware/pulls/$1" --arg l "${2:-me}" '{id: 1, pull_request_url: $u, user: {login: $l}}') + GH_STUB_GET_OUTPUT=$(jq -cn --arg u "https://api.github.com/repos/shopware/shopware/pulls/$1" --arg l "${2:-me}" --arg b "${BODY_MARKER}" '{id: 1, node_id: "PRRC_node1", pull_request_url: $u, user: {login: $l}, body: $b}') } -# The viewer query was not sent. -assert_no_viewer_query() { - ! grep -qF -- "viewer { login }" "${GH_ARGS_FILE}" || fail "Expected no viewer query, got: $(cat "${GH_ARGS_FILE}")" +# The author query was not sent. +assert_no_author_query() { + ! grep -qF -- "... on Comment { viewerDidAuthor }" "${GH_ARGS_FILE}" || fail "Expected no author query, got: $(cat "${GH_ARGS_FILE}")" } # gh must not have run: the rejection happens before any API call. @@ -401,9 +408,9 @@ assert_no_mutation() { ! grep -q -- "updatePullRequestReviewComment" "${GH_ARGS_FILE}" || fail "Expected no mutation, got: $(cat "${GH_ARGS_FILE}")" } -# No GraphQL call other than the viewer login query: no pending-review lookup or mutation. +# No GraphQL call other than the author query: no pending-review lookup or mutation. assert_no_graphql_call() { - ! grep -- "graphql" "${GH_ARGS_FILE}" | grep -qvF -- "viewer { login }" || fail "Expected no GraphQL call, got: $(cat "${GH_ARGS_FILE}")" + ! grep -- "graphql" "${GH_ARGS_FILE}" | grep -qvF -- "... on Comment { viewerDidAuthor }" || fail "Expected no GraphQL call, got: $(cat "${GH_ARGS_FILE}")" } # A failed call reports through an error message that starts with "Error:". @@ -421,7 +428,7 @@ assert_error_output() { assert_output "https://github.com/shopware/shopware/issues/7#issuecomment-9001" assert_gh_call_count 3 assert_gh_call_equal 1 "api repos/shopware/shopware/issues/comments/9001" - assert_gh_call_equal 2 "${VIEWER_CALL}" + assert_gh_call_equal 2 "${AUTHOR_CALL}IC_node1" assert_gh_call_equal 3 "api repos/shopware/shopware/issues/comments/9001 -X PATCH -f body=New text --jq .html_url // empty" } @@ -454,26 +461,6 @@ assert_error_output() { assert_no_rest_write } -@test "comment_edit refuses an issue comment whose issue_url ends in a trailing slash, with no write" { - GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/shopware/shopware/issues/7/"}' - GH_STUB_WRITE_OUTPUT="https://example/c" - run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' - assert_failure - assert_error_output - assert_gh_call_count 1 - assert_no_rest_write -} - -@test "comment_edit refuses an inline comment whose pull_request_url ends in a trailing slash, with no write" { - GH_STUB_GET_OUTPUT='{"id":1,"pull_request_url":"https://api.github.com/repos/shopware/shopware/pulls/100/"}' - GH_STUB_WRITE_OUTPUT="https://example/c" - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_error_output - assert_gh_call_count 1 - assert_no_rest_write -} - @test "comment_edit fails with gh's message when the conversation comment does not exist, and does not patch" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" @@ -496,11 +483,13 @@ assert_error_output() { assert_no_rest_write } -@test "comment_edit fails when the read of a conversation comment names no issue, and does not patch" { - GH_STUB_GET_OUTPUT='{"id":9001}' +@test "comment_edit fails when the read of a conversation comment names no issue, without repeating the comment, and does not patch" { + GH_STUB_GET_OUTPUT=$(jq -cn --arg b "${BODY_MARKER}" '{id: 9001, body: $b}') run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' assert_failure assert_error_output + assert_output --partial "GET repos/shopware/shopware/issues/comments/9001 returned no issue_url" + refute_output --partial "${BODY_MARKER}" assert_gh_call_count 1 assert_no_rest_write } @@ -519,24 +508,6 @@ assert_error_output() { assert_no_graphql_call } -@test "comment_edit patches an inline review comment from an r anchor on /files" { - stub_get_pull 100 - GH_STUB_WRITE_OUTPUT="https://example/c" - run tool_comment_edit '{"url": "shopware/shopware/pull/100/files#r5552", "body": "New text"}' - assert_success - assert_gh_last_args_equal "api repos/shopware/shopware/pulls/comments/5552 -X PATCH -f body=New text --jq .html_url // empty" - assert_no_graphql_call -} - -@test "comment_edit patches an inline review comment from an r anchor on /changes" { - stub_get_pull 100 - GH_STUB_WRITE_OUTPUT="https://example/c" - run tool_comment_edit '{"url": "shopware/shopware/pull/100/changes#r5553", "body": "New text"}' - assert_success - assert_gh_last_args_equal "api repos/shopware/shopware/pulls/comments/5553 -X PATCH -f body=New text --jq .html_url // empty" - assert_no_graphql_call -} - @test "comment_edit refuses to edit an inline comment that belongs to another PR, with no write and no lookup" { stub_get_pull 101 GH_STUB_WRITE_OUTPUT="https://example/c" @@ -617,20 +588,6 @@ assert_error_output() { assert_no_mutation } -@test "comment_edit fails when the 404 comment is not found and the caller has no pending review" { - GH_STUB_GET_EXIT=1 - GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT="${PENDING_NONE}" - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_error_output - assert_output --partial "returned 404" - assert_output --partial "pending-review lookup on PR 100: no matching comment (you have no pending review on this PR)" - assert_gh_call_count 2 - assert_no_rest_write - assert_no_mutation -} - @test "comment_edit finds the comment on the second page of a large pending review and edits it with that node id" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" @@ -676,76 +633,70 @@ assert_error_output() { assert_no_mutation } -@test "comment_edit fails and sends no mutation when the second page of comments cannot be read" { +@test "comment_edit stops at the page limit when every page reports more, with no mutation" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" GH_STUB_LOOKUP_OUTPUT="${PENDING_PAGED}" - GH_STUB_PAGE_CUR1_EXIT=7 - GH_STUB_PAGE_CUR1_STDERR="gh: Bad Gateway (HTTP 502)" + GH_STUB_PAGE_CUR1_OUTPUT='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_b","fullDatabaseId":"2"}],"pageInfo":{"hasNextPage":true,"endCursor":"CUR1"}}}}}' run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure 7 + assert_failure assert_error_output - assert_output --partial "returned 404" - assert_output --partial "Bad Gateway (HTTP 502)" - assert_gh_call_count 3 + assert_output --partial "pending-review lookup on PR 100: reached the limit of 50 comment pages without finding comment 5551" + assert_gh_call_count 51 assert_no_rest_write assert_no_mutation } -@test "comment_edit fails and sends no mutation when the second page carries GraphQL errors" { +@test "comment_edit fails and sends no mutation when the second page of comments cannot be read" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" GH_STUB_LOOKUP_OUTPUT="${PENDING_PAGED}" - GH_STUB_PAGE_CUR1_OUTPUT='{"errors":[{"message":"Rate limited"}],"data":null}' + GH_STUB_PAGE_CUR1_EXIT=7 + GH_STUB_PAGE_CUR1_STDERR="gh: Bad Gateway (HTTP 502)" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure + assert_failure 7 assert_error_output - assert_output --partial "Rate limited" + assert_output --partial "returned 404" + assert_output --partial "Bad Gateway (HTTP 502)" assert_gh_call_count 3 + assert_no_rest_write assert_no_mutation } -@test "comment_edit fails and sends no mutation when the second page has no comments object" { +@test "comment_edit fails and sends no mutation when the second page lacks the expected data, without repeating the page" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" GH_STUB_LOOKUP_OUTPUT="${PENDING_PAGED}" - GH_STUB_PAGE_CUR1_OUTPUT='{"data":{"node":null}}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_error_output - assert_output --partial "no comments for your pending review" - assert_gh_call_count 3 - assert_no_mutation + local answer + for answer in '{"data":{"node":null}}' \ + '{"data":{"node":{"comments":"SECRET-BODY-TEXT"}}}' \ + '{"data":"SECRET-BODY-TEXT"}'; do + GH_STUB_PAGE_CUR1_OUTPUT="${answer}" + : > "${GH_ARGS_FILE}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + [[ "${status}" -ne 0 ]] || fail "Expected the answer ${answer} to fail" + assert_error_output + assert_output --partial "pending-review lookup on PR 100: unexpected response from GitHub for comment page 2 of your pending review" + refute_output --partial "${BODY_MARKER}" + assert_gh_call_count 3 + assert_no_mutation + done } @test "comment_edit edits a matching pending-review comment even when the review has further pages" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_node5551","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":true}}}]}}}}}' + GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"comments":{"nodes":[{"id":"PRRC_node5551","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":true}}}]}}}}}' GH_STUB_MUTATION_OUTPUT="${MUTATION_OK}" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_success assert_gh_call_count 3 } -@test "comment_edit ignores a match in another author's pending review when the caller's own review lacks it" { +@test "comment_edit searches every returned pending review and edits the match from the second one" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":false,"comments":{"nodes":[{"id":"PRRC_theirs","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false}}},{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_mine","fullDatabaseId":"4000"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_error_output - assert_output --partial "returned 404" - assert_output --partial "pending-review lookup on PR 100: no matching comment (it is not in your pending review)" - assert_gh_call_count 2 - assert_no_rest_write - assert_no_mutation -} - -@test "comment_edit edits with the node id from the caller's review when it is not the first pending review" { - GH_STUB_GET_EXIT=1 - GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":false,"comments":{"nodes":[{"id":"PRRC_theirs","fullDatabaseId":"4000"}],"pageInfo":{"hasNextPage":false}}},{"viewerDidAuthor":true,"comments":{"nodes":[{"id":"PRRC_mine","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' + GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"comments":{"nodes":[{"id":"PRRC_theirs","fullDatabaseId":"4000"}],"pageInfo":{"hasNextPage":false}}},{"comments":{"nodes":[{"id":"PRRC_mine","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' GH_STUB_MUTATION_OUTPUT="${MUTATION_OK}" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_success @@ -753,32 +704,6 @@ assert_error_output() { assert_gh_last_args_contain_all "-f id=PRRC_mine" } -@test "comment_edit fails when no pending review belongs to the caller, even if another's holds the comment" { - GH_STUB_GET_EXIT=1 - GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":false},"nodes":[{"viewerDidAuthor":false,"comments":{"nodes":[{"id":"PRRC_theirs","fullDatabaseId":"5551"}],"pageInfo":{"hasNextPage":false}}}]}}}}}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_error_output - assert_output --partial "pending-review lookup on PR 100: no matching comment (you have no pending review on this PR)" - assert_gh_call_count 2 - assert_no_rest_write - assert_no_mutation -} - -@test "comment_edit fails when the caller's review is not among more than 100 pending reviews" { - GH_STUB_GET_EXIT=1 - GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":{"reviews":{"pageInfo":{"hasNextPage":true},"nodes":[{"viewerDidAuthor":false,"comments":{"nodes":[],"pageInfo":{"hasNextPage":false}}}]}}}}}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_error_output - assert_output --partial "returned 404" - assert_output --partial "pending-review lookup on PR 100: your pending review could not be located among more than 100 pending reviews" - assert_gh_call_count 2 - assert_no_rest_write -} - @test "comment_edit fails when the pending-review lookup exits non-zero, with gh's message and no mutation" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" @@ -806,18 +731,6 @@ assert_error_output() { assert_no_mutation } -@test "comment_edit fails when the lookup response carries GraphQL errors" { - GH_STUB_GET_EXIT=1 - GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT='{"errors":[{"message":"Something broke"}],"data":null}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_error_output - assert_output --partial "returned 404" - assert_output --partial "Something broke" - assert_gh_call_count 2 -} - @test "comment_edit fails when the lookup succeeds with no output" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" @@ -850,15 +763,22 @@ assert_error_output() { assert_gh_call_count 2 } -@test "comment_edit fails when the lookup response has no pull request" { +@test "comment_edit fails when the lookup response lacks the expected data, without repeating the response" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT='{"data":{"repository":{"pullRequest":null}}}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_error_output - assert_output --partial "no pull request data" - assert_gh_call_count 2 + local answer + for answer in '{"data":{"repository":{"pullRequest":null}}}' \ + '{"data":{"repository":{"pullRequest":"SECRET-BODY-TEXT"}}}' \ + '{"data":"SECRET-BODY-TEXT"}'; do + GH_STUB_LOOKUP_OUTPUT="${answer}" + : > "${GH_ARGS_FILE}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + [[ "${status}" -ne 0 ]] || fail "Expected the answer ${answer} to fail" + assert_error_output + assert_output --partial "pending-review lookup on PR 100: unexpected response from GitHub for the pending reviews" + refute_output --partial "${BODY_MARKER}" + assert_gh_call_count 2 + done } @test "comment_edit fails with gh's message when the mutation exits non-zero, with no REST write" { @@ -876,80 +796,19 @@ assert_error_output() { assert_no_rest_write } -@test "comment_edit fails when the mutation response carries errors" { - GH_STUB_GET_EXIT=1 - GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" - GH_STUB_MUTATION_OUTPUT='{"errors":[{"message":"Body cannot be blank"}]}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_error_output - assert_output --partial "Body cannot be blank" - assert_output --partial "GET repos/shopware/shopware/pulls/comments/5551 returned 404 (not found, or no access to the repository); pending-review comment edit failed" - assert_no_rest_write -} - -@test "comment_edit fails when the mutation succeeds with no output" { - GH_STUB_GET_EXIT=1 - GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" - GH_STUB_MUTATION_OUTPUT="" - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_error_output - assert_output --partial "pending-review comment edit failed" - assert_output --partial "GET repos/shopware/shopware/pulls/comments/5551 returned 404 (not found, or no access to the repository); pending-review comment edit failed" -} - -@test "comment_edit fails when the mutation response has a null url" { - GH_STUB_GET_EXIT=1 - GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" - GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":null}}}}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_error_output - assert_output --partial "GET repos/shopware/shopware/pulls/comments/5551 returned 404 (not found, or no access to the repository); the pending-review edit was sent and GitHub reported success but returned no URL, so the comment may already hold the new body" -} - -@test "comment_edit fails when the mutation response has a wrongly typed data path, with no jq diagnostic" { +@test "comment_edit fails when the mutation response holds no url" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" - GH_STUB_MUTATION_OUTPUT='{"data":{"updatePullRequestReviewComment":"oops"}}' - run --separate-stderr tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - [[ "${lines[0]}" == Error:* ]] || fail "Expected first line to start with Error:, got: ${lines[0]}" - [[ -z "${stderr}" ]] || fail "Expected no stderr, got: ${stderr}" -} - -@test "comment_edit fails without a mutation when GitHub returns the cursor just used, after a bounded number of calls" { - GH_STUB_GET_EXIT=1 - GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT="${PENDING_PAGED}" - GH_STUB_PAGE_CUR1_OUTPUT='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_b","fullDatabaseId":"2"}],"pageInfo":{"hasNextPage":true,"endCursor":"CUR1"}}}}}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_error_output - assert_output --partial "did not advance" - assert_gh_call_count 3 - assert_no_rest_write - assert_no_mutation -} - -@test "comment_edit fails without a mutation when pages alternate between two cursors, after a bounded number of calls" { - GH_STUB_GET_EXIT=1 - GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" - GH_STUB_LOOKUP_OUTPUT="${PENDING_PAGED}" - GH_STUB_PAGE_CUR1_OUTPUT="${PAGE_MORE}" - GH_STUB_PAGE_CUR2_OUTPUT='{"data":{"node":{"comments":{"nodes":[{"id":"PRRC_c","fullDatabaseId":"3"}],"pageInfo":{"hasNextPage":true,"endCursor":"CUR1"}}}}}' - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' - assert_failure - assert_error_output - assert_output --partial "did not advance (cursor CUR1 came back)" - assert_gh_call_count 4 - assert_no_rest_write - assert_no_mutation + local answer + for answer in '{"data":{"updatePullRequestReviewComment":{"pullRequestReviewComment":{"url":null}}}}' \ + '{"data":{"updatePullRequestReviewComment":"oops"}}'; do + GH_STUB_MUTATION_OUTPUT="${answer}" + run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' + [[ "${status}" -ne 0 ]] || fail "Expected the answer ${answer} to fail" + assert_error_output + assert_output --partial "GET repos/shopware/shopware/pulls/comments/5551 returned 404 (not found, or no access to the repository); the pending-review edit was sent and GitHub reported success but returned no URL, so the comment may already hold the new body" + done } @test "comment_edit names the failed call and keeps gh's exit status when the mutation fails with no output" { @@ -967,7 +826,7 @@ assert_error_output() { # ---- review summary body ------------------------------------------------------ @test "comment_edit reads a review summary, then puts the body on the PR's reviews endpoint" { - GH_STUB_GET_OUTPUT='{"id":777,"user":{"login":"me"}}' + GH_STUB_GET_OUTPUT='{"id":777,"node_id":"PRR_node777","user":{"login":"me"}}' GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-777", "body": "New summary"}' assert_success @@ -989,7 +848,7 @@ assert_error_output() { } @test "comment_edit returns the clean URL when a successful review PUT prints a warning on stderr" { - GH_STUB_GET_OUTPUT='{"id":777,"user":{"login":"me"}}' + GH_STUB_GET_OUTPUT='{"id":777,"node_id":"PRR_node777","user":{"login":"me"}}' GH_STUB_WRITE_STDERR="${WARNING_STDERR}" GH_STUB_WRITE_OUTPUT="https://example/review" run --separate-stderr tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-777", "body": "x"}' @@ -1011,17 +870,30 @@ assert_error_output() { # ---- author guard ------------------------------------------------------------- -@test "comment_edit edits a comment written by the caller, comparing logins without regard to case" { - stub_get_issue 7 "ME" +@test "comment_edit asks GitHub about the node id of the comment it read, for each kind, before it writes" { GH_STUB_WRITE_OUTPUT="https://example/c" - run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' - assert_success - assert_output "https://example/c" - assert_gh_call_equal 2 "${VIEWER_CALL}" + local row kind_url node + for row in "shopware/shopware/issues/7#issuecomment-9001|IC_node1" \ + "https://github.com/shopware/shopware/pull/100#discussion_r5551|PRRC_node1" \ + "https://github.com/shopware/shopware/pull/100#pullrequestreview-777|PRR_node777"; do + kind_url="${row%%|*}" + node="${row##*|}" + case "${node}" in + IC_*) stub_get_issue 7 ;; + PRRC_*) stub_get_pull 100 ;; + *) GH_STUB_GET_OUTPUT='{"id":777,"node_id":"PRR_node777","user":{"login":"me"}}' ;; + esac + : > "${GH_ARGS_FILE}" + run tool_comment_edit "{\"url\": \"${kind_url}\", \"body\": \"x\"}" + [[ "${status}" -eq 0 ]] || fail "Expected '${kind_url}' to be edited, got: ${output}" + assert_gh_call_count 3 + assert_gh_call_equal 2 "${AUTHOR_CALL}${node}" + done } -@test "comment_edit refuses another author's conversation comment by default, naming both logins, with no write" { +@test "comment_edit refuses another author's conversation comment by default, naming the author, with no write" { stub_get_issue 7 "octocat" + GH_STUB_AUTHOR_OUTPUT="${AUTHOR_NO}" GH_STUB_WRITE_OUTPUT="https://example/c" local json for json in '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' \ @@ -1030,9 +902,9 @@ assert_error_output() { run tool_comment_edit "${json}" assert_failure assert_error_output - assert_output --partial "octocat" - assert_output --partial "(me)" + assert_output --partial "written by octocat" assert_output --partial "allow_other_author: true" + refute_output --partial "${BODY_MARKER}" assert_gh_call_count 2 assert_no_rest_write done @@ -1040,44 +912,61 @@ assert_error_output() { @test "comment_edit refuses another author's inline comment by default, with no write" { stub_get_pull 100 "octocat" + GH_STUB_AUTHOR_OUTPUT="${AUTHOR_NO}" GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_failure assert_error_output - assert_output --partial "octocat" + assert_output --partial "written by octocat" assert_no_rest_write } @test "comment_edit refuses another author's review summary by default, with no PUT" { - GH_STUB_GET_OUTPUT='{"id":777,"user":{"login":"octocat"}}' + GH_STUB_GET_OUTPUT='{"id":777,"node_id":"PRR_node777","user":{"login":"octocat"}}' + GH_STUB_AUTHOR_OUTPUT="${AUTHOR_NO}" GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-777", "body": "x"}' assert_failure assert_error_output - assert_output --partial "octocat" + assert_output --partial "written by octocat" assert_output --partial "allow_other_author: true" assert_gh_call_equal 1 "api repos/shopware/shopware/pulls/100/reviews/777" + assert_gh_call_equal 2 "${AUTHOR_CALL}PRR_node777" assert_no_rest_write } -@test "comment_edit edits another author's comment when allow_other_author is true, without asking who the caller is" { +@test "comment_edit names a deleted user when the comment has no author and GitHub says the caller did not write it" { + GH_STUB_GET_OUTPUT='{"id":1,"node_id":"IC_node1","issue_url":"https://api.github.com/repos/shopware/shopware/issues/7","user":null}' + GH_STUB_AUTHOR_OUTPUT="${AUTHOR_NO}" + GH_STUB_WRITE_OUTPUT="https://example/c" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + assert_failure + assert_error_output + assert_output --partial "written by a deleted user" + assert_output --partial "allow_other_author: true" + assert_no_rest_write +} + +@test "comment_edit edits another author's comment when allow_other_author is true, without asking GitHub who wrote it" { stub_get_issue 7 "octocat" + GH_STUB_AUTHOR_OUTPUT="${AUTHOR_NO}" GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x", "allow_other_author": true}' assert_success assert_output "https://example/c" assert_gh_call_count 2 - assert_no_viewer_query + assert_no_author_query } @test "comment_edit edits another author's review summary when allow_other_author is true" { GH_STUB_GET_OUTPUT='{"id":777,"user":{"login":"octocat"}}' + GH_STUB_AUTHOR_OUTPUT="${AUTHOR_NO}" GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-777", "body": "x", "allow_other_author": true}' assert_success assert_gh_call_count 2 assert_gh_call_equal 2 "api repos/shopware/shopware/pulls/100/reviews/777 -X PUT -f body=x --jq .html_url // empty" - assert_no_viewer_query + assert_no_author_query } @test "comment_edit fails when the review summary cannot be read, with no PUT" { @@ -1091,46 +980,49 @@ assert_error_output() { assert_no_rest_write } -@test "comment_edit fails with no write when the caller's login cannot be read" { +@test "comment_edit fails with no write when the author query exits non-zero" { stub_get_issue 7 - GH_STUB_VIEWER_EXIT=1 - GH_STUB_VIEWER_STDERR="gh: Bad credentials (HTTP 401)" + GH_STUB_AUTHOR_EXIT=1 + GH_STUB_AUTHOR_STDERR="gh: Bad credentials (HTTP 401)" GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' assert_failure assert_error_output assert_output --partial "Bad credentials" + assert_gh_call_count 2 assert_no_rest_write } -@test "comment_edit fails with no write when the login query answers with GraphQL errors" { - stub_get_issue 7 - GH_STUB_VIEWER_OUTPUT='{"errors":[{"message":"Resource not accessible"}]}' - GH_STUB_WRITE_OUTPUT="https://example/c" - run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' - assert_failure - assert_error_output - assert_output --partial "Resource not accessible" - assert_no_rest_write -} - -@test "comment_edit fails with no write when the login query returns no login" { +@test "comment_edit refuses with no write when the author answer is anything but true" { stub_get_issue 7 - GH_STUB_VIEWER_OUTPUT='{"data":{"viewer":{"login":null}}}' GH_STUB_WRITE_OUTPUT="https://example/c" - run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' - assert_failure - assert_error_output - assert_no_rest_write + local answer + for answer in '{"data":{"node":null}}' \ + '{"data":{"node":{}}}' \ + '{"data":{"node":{"viewerDidAuthor":null}}}' \ + '{"data":{"node":{"viewerDidAuthor":"true"}}}' \ + '{"data":{"node":"oops"}}'; do + GH_STUB_AUTHOR_OUTPUT="${answer}" + : > "${GH_ARGS_FILE}" + run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' + [[ "${status}" -ne 0 ]] || fail "Expected the answer ${answer} to fail" + assert_error_output + assert_output --partial "written by me, not by you" + assert_output --partial "allow_other_author: true" + assert_gh_call_count 2 + assert_no_rest_write + done } -@test "comment_edit fails with no write when the comment names no author, unless allow_other_author is true" { - GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/shopware/shopware/issues/7","user":null}' +@test "comment_edit fails with no write and no author query when the comment read has no node_id, unless allow_other_author is true" { + GH_STUB_GET_OUTPUT=$(jq -cn --arg b "${BODY_MARKER}" '{id: 1, issue_url: "https://api.github.com/repos/shopware/shopware/issues/7", user: {login: "me"}, body: $b}') GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "shopware/shopware/issues/7#issuecomment-9001", "body": "x"}' assert_failure assert_error_output - assert_output --partial "allow_other_author: true" + assert_output --partial "Error: comment_edit: unexpected response from GitHub for GET repos/shopware/shopware/issues/comments/9001" + refute_output --partial "${BODY_MARKER}" + assert_gh_call_count 1 assert_no_rest_write : > "${GH_ARGS_FILE}" @@ -1138,20 +1030,20 @@ assert_error_output() { assert_success } -@test "comment_edit does not ask who the caller is for a comment in the caller's own pending review" { +@test "comment_edit does not ask who wrote a comment in the caller's own pending review" { GH_STUB_GET_EXIT=1 GH_STUB_GET_STDERR="${NOT_FOUND_STDERR}" GH_STUB_LOOKUP_OUTPUT="${PENDING_MATCH}" GH_STUB_MUTATION_OUTPUT="${MUTATION_OK}" run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#discussion_r5551", "body": "x"}' assert_success - assert_no_viewer_query + assert_no_author_query } # ---- URL handling ----------------------------------------------------------------- @test "comment_edit takes the repository from the URL, not from the default repo" { - GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/other-org/other.repo/issues/7","user":{"login":"me"}}' + GH_STUB_GET_OUTPUT='{"id":1,"node_id":"IC_node1","issue_url":"https://api.github.com/repos/other-org/other.repo/issues/7","user":{"login":"me"}}' GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "other-org/other.repo/issues/7#issuecomment-1", "body": "x"}' assert_success @@ -1159,30 +1051,34 @@ assert_error_output() { assert_gh_call_equal 3 "api repos/other-org/other.repo/issues/comments/1 -X PATCH -f body=x --jq .html_url // empty" } -@test "comment_edit strips the http and www.github.com prefixes" { - stub_get_issue 7 - GH_STUB_WRITE_OUTPUT="https://example/c" - local prefix - for prefix in "http://github.com/" "https://www.github.com/"; do - : > "${GH_ARGS_FILE}" - run tool_comment_edit "{\"url\": \"${prefix}shopware/shopware/issues/7#issuecomment-3\", \"body\": \"x\"}" - assert_success - assert_gh_last_args_equal "api repos/shopware/shopware/issues/comments/3 -X PATCH -f body=x --jq .html_url // empty" - done -} - -@test "comment_edit accepts a query string before the anchor and the commit and range forms of an inline anchor" { +@test "comment_edit reaches the expected first gh call for each accepted URL form" { local sha40="0123456789abcdef0123456789abcdef01234567" - GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/shopware/shopware/issues/100","pull_request_url":"https://api.github.com/repos/shopware/shopware/pulls/100","user":{"login":"me"}}' + GH_STUB_GET_OUTPUT='{"id":1,"node_id":"IC_node1","issue_url":"https://api.github.com/repos/shopware/shopware/issues/100","pull_request_url":"https://api.github.com/repos/shopware/shopware/pulls/100","user":{"login":"me"}}' GH_STUB_WRITE_OUTPUT="https://example/c" local row u endpoint for row in \ + "http://github.com/shopware/shopware/issues/7#issuecomment-3|repos/shopware/shopware/issues/comments/3" \ + "https://www.github.com/shopware/shopware/issues/7#issuecomment-3|repos/shopware/shopware/issues/comments/3" \ + "https://GitHub.com/Shopware/Shopware/issues/7#issuecomment-3|repos/Shopware/Shopware/issues/comments/3" \ + "HTTPS://github.com/Shopware/Shopware/issues/7#issuecomment-3|repos/Shopware/Shopware/issues/comments/3" \ + "http://GITHUB.COM/Shopware/Shopware/issues/7#issuecomment-3|repos/Shopware/Shopware/issues/comments/3" \ + "https://WWW.GitHub.com/Shopware/Shopware/issues/7#issuecomment-3|repos/Shopware/Shopware/issues/comments/3" \ + "https://www.GITHUB.com/Shopware/Shopware/issues/7#issuecomment-3|repos/Shopware/Shopware/issues/comments/3" \ "shopware/shopware/issues/100?notification_referrer_id=NT_x#issuecomment-3|repos/shopware/shopware/issues/comments/3" \ "https://github.com/shopware/shopware/pull/100?notification_referrer_id=NT_x#issuecomment-3|repos/shopware/shopware/issues/comments/3" \ "shopware/shopware/pull/100?notification_referrer_id=NT_x#discussion_r5551|repos/shopware/shopware/pulls/comments/5551" \ + "shopware/shopware/pull/100/files#r5552|repos/shopware/shopware/pulls/comments/5552" \ + "shopware/shopware/pull/100/changes#r5553|repos/shopware/shopware/pulls/comments/5553" \ "shopware/shopware/pull/100/files?w=1#r5552|repos/shopware/shopware/pulls/comments/5552" \ "shopware/shopware/pull/100/files/${sha40}..${sha40}#r5553|repos/shopware/shopware/pulls/comments/5553" \ "shopware/shopware/pull/100/files/abc1234..def5678#r5554|repos/shopware/shopware/pulls/comments/5554" \ + "shopware/shopware/pull/100/files/${sha40}#r5558|repos/shopware/shopware/pulls/comments/5558" \ + "shopware/shopware/pull/100/files/abc1234#r5559|repos/shopware/shopware/pulls/comments/5559" \ + "shopware/shopware/pull/100/changes/${sha40}#r5560|repos/shopware/shopware/pulls/comments/5560" \ + "shopware/shopware/pull/100/changes/abc1234#r5561|repos/shopware/shopware/pulls/comments/5561" \ + "shopware/shopware/pull/100/changes/${sha40}..${sha40}#r5562|repos/shopware/shopware/pulls/comments/5562" \ + "shopware/shopware/pull/100/changes/abc1234..def5678#r5563|repos/shopware/shopware/pulls/comments/5563" \ + "shopware/shopware/pull/100/changes/abc1234..def5678?w=1#r5564|repos/shopware/shopware/pulls/comments/5564" \ "shopware/shopware/pull/100/commits/${sha40}#r5555|repos/shopware/shopware/pulls/comments/5555" \ "shopware/shopware/pull/100/commits/abc1234#r5556|repos/shopware/shopware/pulls/comments/5556" \ "shopware/shopware/pull/100/commits/${sha40}?notification_referrer_id=NT_x#r5557|repos/shopware/shopware/pulls/comments/5557" \ @@ -1190,14 +1086,32 @@ assert_error_output() { u="${row%%|*}" endpoint="${row##*|}" : > "${GH_ARGS_FILE}" - rm -f "${BATS_TEST_TMPDIR}"/gh_argv.* run tool_comment_edit "{\"url\": \"${u}\", \"body\": \"x\"}" - [[ "${status}" -eq 0 ]] || fail "Expected '${u}' to be accepted, got: ${output}" assert_gh_call_equal 1 "api ${endpoint}" - assert_gh_call_contains_all 3 "api ${endpoint} -X " done } +@test "comment_edit rejects an anchor that does not fit the kind or suffix of the URL, before any gh call" { + local u + for u in "https://github.com/shopware/shopware/pull/100" \ + "https://github.com/shopware/shopware/pull/100#event-5" \ + "https://github.com/shopware/shopware/pull/100#issuecomment-abc" \ + "https://github.com/shopware/shopware/pull/abc#issuecomment-5" \ + "https://github.com/shopware/shopware/pull/100#pullrequestreview-" \ + "https://github.com/shopware/shopware/issues/7#discussion_r5" \ + "https://github.com/shopware/shopware/issues/7/files#r5" \ + "https://github.com/shopware/shopware/issues/7#pullrequestreview-5" \ + "https://github.com/shopware/shopware/pull/100#r5" \ + "https://github.com/shopware/shopware/pull/100/files#discussion_r5" \ + "https://github.com/shopware/shopware/pull/100/files#pullrequestreview-5" \ + "https://github.com/shopware/shopware/pull/100/commits#issuecomment-5"; do + run tool_comment_edit "{\"url\": \"${u}\", \"body\": \"x\"}" + [[ "${status}" -ne 0 ]] || fail "Expected '${u}' to be rejected" + [[ "${output}" == "Error: url must be "* ]] || fail "Expected the form error for '${u}', got: ${output}" + done + assert_gh_not_called +} + @test "comment_edit rejects a malformed sha segment, a misplaced query string, and a sha form on an issues URL before any gh call" { local sha41="0123456789abcdef0123456789abcdef012345678" local u @@ -1206,7 +1120,20 @@ assert_error_output() { "shopware/shopware/pull/100/commits/${sha41}#r5" \ "shopware/shopware/pull/100/commits/abc1234..def5678#r5" \ "shopware/shopware/pull/100/commits/abc1234/files#r5" \ - "shopware/shopware/pull/100/files/abc1234#r5" \ + "shopware/shopware/pull/100/files/abc123#r5" \ + "shopware/shopware/pull/100/files/${sha41}#r5" \ + "shopware/shopware/pull/100/files/abc1234#discussion_r5" \ + "shopware/shopware/pull/100/files/abc1234/files#r5" \ + "shopware/shopware/pull/100/changes/abc123#r5" \ + "shopware/shopware/pull/100/changes/xyz1234#r5" \ + "shopware/shopware/pull/100/changes/${sha41}#r5" \ + "shopware/shopware/pull/100/changes/abc1234..#r5" \ + "shopware/shopware/pull/100/changes/abc1234..def567#r5" \ + "shopware/shopware/pull/100/changes/abc1234...def5678#r5" \ + "shopware/shopware/pull/100/changes/abc1234#issuecomment-5" \ + "shopware/shopware/pull/100/changes/abc1234#pullrequestreview-5" \ + "shopware/shopware/issues/100/changes/abc1234#r5" \ + "shopware/shopware/issues/100/files/abc1234#r5" \ "shopware/shopware/pull/100/files/abc1234..#r5" \ "shopware/shopware/pull/100/files/abc1234..def567#r5" \ "shopware/shopware/pull/100/files/abc1234...def5678#r5" \ @@ -1222,8 +1149,18 @@ assert_error_output() { assert_gh_not_called } +@test "comment_edit rejects a host that only starts with github.com, in any case, before any gh call" { + local prefix + for prefix in "https://GitHub.com.example.org/" "https://WWW.github.com.example.org/" "https://github.community/" "https://gist.github.com/"; do + run tool_comment_edit "{\"url\": \"${prefix}shopware/shopware/issues/7#issuecomment-3\", \"body\": \"x\"}" + [[ "${status}" -ne 0 ]] || fail "Expected '${prefix}' to be rejected" + assert_output --partial "must be a github.com URL" + done + assert_gh_not_called +} + @test "comment_edit accepts an owner with an underscore and reaches gh" { - GH_STUB_GET_OUTPUT='{"id":1,"issue_url":"https://api.github.com/repos/mona_octocorp/repo/issues/7","user":{"login":"me"}}' + GH_STUB_GET_OUTPUT='{"id":1,"node_id":"IC_node1","issue_url":"https://api.github.com/repos/mona_octocorp/repo/issues/7","user":{"login":"me"}}' GH_STUB_WRITE_OUTPUT="https://example/c" run tool_comment_edit '{"url": "mona_octocorp/repo/issues/7#issuecomment-1", "body": "x"}' assert_success @@ -1232,32 +1169,6 @@ assert_error_output() { assert_gh_call_equal 3 "api repos/mona_octocorp/repo/issues/comments/1 -X PATCH -f body=x --jq .html_url // empty" } -@test "comment_edit rejects an issue or PR number with a leading zero before any gh call" { - local u - for u in "shopware/shopware/issues/07#issuecomment-5" \ - "shopware/shopware/pull/0100#discussion_r5" \ - "shopware/shopware/pull/0#issuecomment-5"; do - run tool_comment_edit "{\"url\": \"${u}\", \"body\": \"x\"}" - [[ "${status}" -ne 0 ]] || fail "Expected '${u}' to be rejected" - [[ "${output}" == "Error: url must be "* ]] || fail "Expected the form error for '${u}', got: ${output}" - done - assert_gh_not_called -} - -@test "comment_edit rejects a comment or review ID with a leading zero before any gh call" { - local u - for u in "shopware/shopware/issues/7#issuecomment-05" \ - "shopware/shopware/pull/100#discussion_r05" \ - "shopware/shopware/pull/100/files#r05" \ - "shopware/shopware/pull/100/changes#r0" \ - "shopware/shopware/pull/100#pullrequestreview-05"; do - run tool_comment_edit "{\"url\": \"${u}\", \"body\": \"x\"}" - [[ "${status}" -ne 0 ]] || fail "Expected '${u}' to be rejected" - [[ "${output}" == "Error: url must be "* ]] || fail "Expected the form error for '${u}', got: ${output}" - done - assert_gh_not_called -} - @test "comment_edit requires url" { run tool_comment_edit '{"body": "x"}' assert_failure @@ -1288,97 +1199,6 @@ assert_error_output() { assert_gh_not_called } -@test "comment_edit rejects a URL without an anchor" { - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100", "body": "x"}' - assert_failure - assert_output --partial "url must be" - assert_gh_not_called -} - -@test "comment_edit rejects an unknown anchor" { - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#event-5", "body": "x"}' - assert_failure - assert_output --partial "url must be" - assert_gh_not_called -} - -@test "comment_edit rejects a non-numeric comment ID" { - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#issuecomment-abc", "body": "x"}' - assert_failure - assert_output --partial "url must be" - assert_gh_not_called -} - -@test "comment_edit rejects a non-numeric issue or PR number" { - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/abc#issuecomment-5", "body": "x"}' - assert_failure - assert_output --partial "url must be" - assert_gh_not_called -} - -@test "comment_edit rejects a review-comment anchor on an issues URL" { - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/issues/7#discussion_r5", "body": "x"}' - assert_failure - assert_output --partial "url must be" - assert_gh_not_called -} - -@test "comment_edit rejects an r anchor on an issues/N/files URL" { - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/issues/7/files#r5", "body": "x"}' - assert_failure - assert_output --partial "url must be" - assert_gh_not_called -} - -@test "comment_edit rejects a review anchor on an issues URL" { - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/issues/7#pullrequestreview-5", "body": "x"}' - assert_failure - assert_output --partial "url must be" - assert_gh_not_called -} - -@test "comment_edit rejects an r anchor without /files or /changes" { - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#r5", "body": "x"}' - assert_failure - assert_output --partial "url must be" - assert_gh_not_called -} - -@test "comment_edit rejects a discussion_r anchor on a /files URL" { - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100/files#discussion_r5", "body": "x"}' - assert_failure - assert_output --partial "url must be" - assert_gh_not_called -} - -@test "comment_edit rejects a review anchor on a /files URL" { - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100/files#pullrequestreview-5", "body": "x"}' - assert_failure - assert_output --partial "url must be" - assert_gh_not_called -} - -@test "comment_edit rejects a non-numeric review ID" { - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100#pullrequestreview-", "body": "x"}' - assert_failure - assert_output --partial "url must be" - assert_gh_not_called -} - -@test "comment_edit rejects extra path segments" { - run tool_comment_edit '{"url": "https://github.com/shopware/shopware/pull/100/commits#issuecomment-5", "body": "x"}' - assert_failure - assert_output --partial "url must be" - assert_gh_not_called -} - -@test "comment_edit rejects a repository name ending in .git" { - run tool_comment_edit '{"url": "owner/repo.git/issues/1#issuecomment-2", "body": "x"}' - assert_failure - assert_output --partial "url must be" - assert_gh_not_called -} - @test "comment_edit rejects a repository named .." { run tool_comment_edit '{"url": "shopware/../issues/7#issuecomment-5", "body": "x"}' assert_failure diff --git a/plugins/github-mcp/AGENTS.md b/plugins/github-mcp/AGENTS.md index fd14381..68cf13b 100644 --- a/plugins/github-mcp/AGENTS.md +++ b/plugins/github-mcp/AGENTS.md @@ -125,7 +125,7 @@ Claude Code / Codex ← stdout ← JSON-RPC response ← formatted output Tools in `tools-read.json` and `tools-write.json` map to bash functions with `tool_` prefix: - Uses bash arrays (`local -a cmd=("gh" "pr" "view" "${number}")`) for injection-safe argument passing - `_gh_resolve_repo()` falls back to `GH_DEFAULT_REPO` from config -- All tools support `suppress_errors` and `fallback` shared parameters, except write tool `comment_edit`, which declares neither so a failed edit always returns an error +- Shared parameters (`suppress_errors`, `fallback`): which tools take them is in `REFERENCE.md` §Shared Tool Parameters - Tools with JSON output support `jq_filter` with pre-execution syntax validation - Tools that accept `grep_pattern` validate it with `_gh_validate_grep_pattern()` before calling `gh` - Log/text tools support `max_lines`, `tail_lines`, and grep parameters diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index a31232b..2ae5603 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -9,38 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `comment_edit` write tool. Replaces the body of one existing comment from the comment's URL. It - edits four kinds: an issue or PR conversation comment (`issues/N#issuecomment-ID`, - `pull/N#issuecomment-ID`), an inline review comment (`pull/N#discussion_rID`, or an `#rID` anchor on - `pull/N/files`, `pull/N/changes`, `pull/N/files/SHA..SHA`, or `pull/N/commits/SHA`; it may sit in - your own pending review), and a review's summary body (`pull/N#pullrequestreview-ID`). Commit - comments and Discussions comments are not supported. The URL may carry a leading - `https://github.com/` and a `?query` before the `#`; a URL on another host, without an anchor, with - an unknown anchor, with a non-numeric number or ID, with a malformed SHA, or with a number or ID - that starts with `0` is rejected before any `gh` call. The owner may contain `_`, as an Enterprise - Managed User account name does. -- `comment_edit` reads the comment before editing it. A conversation or inline comment is edited only - when it belongs to the issue or PR `N` in the URL; a comment of another issue or PR is an error and - nothing is edited. A comment or review summary written by someone else is refused as well, with an - error naming both logins, unless `allow_other_author` is `true` (for moderation). The tool reads the - authenticated login with one GraphQL query, and skips that query when `allow_other_author` is - `true`. A comment in your own pending review needs neither check. -- `comment_edit` handles a comment in your own unsubmitted (pending) review: when the read of an - inline comment answers 404, the tool looks the review up through GraphQL, matching - `fullDatabaseId` and reading further pages of a review with more than 100 comments, and edits a - matching comment with the `updatePullRequestReviewComment` mutation. When the lookup fails or finds - no match, the error states the 404 and the lookup's result. -- `comment_edit` returns every failure as an error that starts with `Error:`, and a warning that - `gh` writes to stderr on a successful call does not fail the edit. It returns the updated - comment's `html_url`, or the `url` for a comment in a pending review. When GitHub reports success - but returns no URL, the call is still an error, and its message says the edit was sent and the - comment may already hold the new body. -- `check-api-tools.sh` redirects `PATCH repos/O/R/issues/comments/ID`, - `PATCH repos/O/R/pulls/comments/ID`, and `PUT repos/O/R/pulls/N/reviews/ID` on the `api` write tool - to `comment_edit`, under `block_api_tool_write`. The message for a conversation comment points to - `issue_view` or `pr_view` with `fields=comments` for the comment's URL. The block messages for - `gh pr comment` and `gh issue comment` now also name `comment_edit` for editing an existing - comment, which covers `--edit-last`. +- `comment_edit` write tool. Edits the body of an existing comment, given the comment's URL: an + issue or PR conversation comment, an inline review comment (including one in your own unsubmitted + review), or a review summary. Commit comments and Discussions comments are not supported. The URL + can be written with or without `https://github.com/`, can carry a notification link's `?query`, and + can point at a comment shown in the Files, Changes, or commit view of a PR. `REFERENCE.md` lists + the exact forms. +- `comment_edit` refuses to edit a submitted comment written by someone else unless you pass + `allow_other_author: true`, and refuses a comment that does not belong to the issue or PR named in + the URL. Every failure comes back as a tool error; an edit that GitHub + accepts but answers without a URL is also an error, and says the comment may already hold the new + body. +- The `api` write tool (under `block_api_tool_write`) and raw `gh api` calls (under + `block_api_commands`) that edit a comment now redirect to `comment_edit`, and the block messages + for `gh pr comment` and `gh issue comment` name `comment_edit` for editing an existing comment. ## [5.0.1] - 2026-10-03 diff --git a/plugins/github-mcp/README.md b/plugins/github-mcp/README.md index 0ada3c7..2c1a759 100644 --- a/plugins/github-mcp/README.md +++ b/plugins/github-mcp/README.md @@ -129,7 +129,7 @@ With enforcement disabled: |------------------------|---------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `repo` | string | -- | Default repository in `owner/repo` format. Used when `repo` is not passed to a tool call, and for `search_code` and `search_commits` only when `owner` is not passed either. | | `enforce_mcp_tools` | boolean | `true` | Blocks high-level `gh` subcommands (`gh pr view`, `gh issue view`, `gh run view`, `gh search`, `gh pr create`, `gh label list`, `gh project view`, etc.) and redirects to MCP tools. Set to `false` to disable all gh hook enforcement. | -| `block_api_commands` | boolean | `false` | When `true` (and `enforce_mcp_tools` is also `true`), additionally blocks `gh api` calls for endpoints that have a dedicated MCP tool: `pulls/N/comments`, `pulls/N/reviews`, `pulls/N/files`, `pulls/N/commits`, `actions/jobs/N/logs`, `actions/jobs/N`, `check-runs/N/annotations`, `commits/SHA`, `releases`. Other `gh api` calls remain unblocked. | +| `block_api_commands` | boolean | `false` | When `true` (and `enforce_mcp_tools` is also `true`), additionally blocks `gh api` calls for endpoints that have a dedicated MCP tool: comment and review edits (`PATCH issues/comments/ID`, `PATCH pulls/comments/ID`, `PUT pulls/N/reviews/ID`), `pulls/N/comments`, `pulls/N/reviews`, `pulls/N/files`, `pulls/N/commits`, `actions/jobs/N/logs`, `actions/jobs/N`, `check-runs/N/annotations`, `commits/SHA`, `releases`. Other `gh api` calls remain unblocked. | | `enable_write_server` | boolean | `false` | When `true`, the write MCP server exposes write tools (PR creation, issue editing, reviews, etc.). When `false` (default), the write server returns an empty tools list. | | `block_api_tool_read` | boolean | `false` | When `true`, the read server's `api_read` tool blocks requests to endpoints that have a dedicated read MCP tool, suggesting the dedicated tool instead. | | `block_api_tool_write` | boolean | `false` | When `true`, the write server's `api` tool blocks requests to endpoints that have a dedicated write MCP tool, suggesting the dedicated tool instead. | @@ -310,6 +310,7 @@ With `block_api_commands: true`, additionally blocks `gh api` bash calls for end | Endpoint Pattern | MCP Tool | |----------------------------|---------------------------| +| `PATCH issues/comments/ID`, `PATCH pulls/comments/ID`, `PUT pulls/N/reviews/ID` | `comment_edit` | | `pulls/N/comments` | `pr_comments` | | `pulls/N/reviews` | `pr_reviews` | | `pulls/N/files` | `pr_files` | diff --git a/plugins/github-mcp/REFERENCE.md b/plugins/github-mcp/REFERENCE.md index f213729..6bc22ea 100644 --- a/plugins/github-mcp/REFERENCE.md +++ b/plugins/github-mcp/REFERENCE.md @@ -735,32 +735,31 @@ Use gh-tooling-write pr_review_reply with number 14642 and comment_id 1234567 an #### `comment_edit` -Replace the body of one existing comment. Four kinds are supported: an issue or PR conversation comment, an inline review comment (including one in your own pending review), and a review's summary body. Commit comments and Discussions comments are not supported. The kind comes from the anchor of `url`, so pass the URL exactly as `issue_comment` or `pr_comment` returned it, as the `url` of a comment from `issue_view` or `pr_view` with `fields=comments`, or as the `html_url` from `pr_comments`, `pr_reviews`, or `pr_review_reply`. The body replaces the whole comment text; send the full new text, not a diff. Returns the updated comment's URL. +Replace the body of one existing comment. Supported: a conversation comment on an issue or a PR, an inline review comment (including one in your own pending review), and a review's summary body. Commit comments and Discussions comments are not supported. The kind comes from the anchor of `url`, so pass the URL exactly as `issue_comment` or `pr_comment` returned it, as the `url` of a comment from `issue_view` or `pr_view` with `fields=comments`, or as the `html_url` from `pr_comments`, `pr_reviews`, or `pr_review_reply`. The body replaces the whole comment text; send the full new text, not a diff. Returns the updated comment's URL. ``` Use gh-tooling-write comment_edit with url "https://github.com/shopware/shopware/pull/14642#issuecomment-2001" and body "CI is green, ready to merge" Use gh-tooling-write comment_edit with url "shopware/shopware/pull/14642#discussion_r1234567" and body "Fixed in b8f9a8c." ``` -Accepted `url` forms, each with or without a leading `https://github.com/` (`http://github.com/` and `https://www.github.com/` also work; any other scheme or host is rejected). A `?query` before the `#`, such as `?notification_referrer_id=NT_x`, is ignored. `OWNER` is letters, digits, `-`, and `_` (an Enterprise Managed User account such as `mona_octocorp` has one). `N` and `ID` are digits only and do not start with `0`. `SHA` is 7 to 40 hex characters: +Accepted `url` forms, each with or without a leading `https://github.com/` (`http://github.com/` and `https://www.github.com/` also work; the scheme and host match without regard to case, so `https://GitHub.com/` works too; any other scheme or host is rejected). A `?query` before the `#`, such as `?notification_referrer_id=NT_x`, is ignored. `OWNER` is letters, digits, `-`, and `_` (an Enterprise Managed User account such as `mona_octocorp` has one). `N` and `ID` are digits only. `SHA` is 7 to 40 hex characters: | `url` form | Edits | Request | |--------------------------------------------------------|---------------------------------|-------------------------------------------------| | `OWNER/REPO/issues/N#issuecomment-ID` | Issue conversation comment | `PATCH repos/OWNER/REPO/issues/comments/ID` | | `OWNER/REPO/pull/N#issuecomment-ID` | PR conversation comment | `PATCH repos/OWNER/REPO/issues/comments/ID` | | `OWNER/REPO/pull/N#discussion_rID` | Inline review comment | `PATCH repos/OWNER/REPO/pulls/comments/ID` (see below) | -| `OWNER/REPO/pull/N/files#rID`, `.../changes#rID`, `.../files/SHA..SHA#rID`, or `.../commits/SHA#rID` | Inline review comment | `PATCH repos/OWNER/REPO/pulls/comments/ID` (see below) | +| `OWNER/REPO/pull/N/files#rID`, `.../changes#rID`, `.../files/SHA#rID`, `.../files/SHA..SHA#rID`, `.../changes/SHA#rID`, `.../changes/SHA..SHA#rID`, or `.../commits/SHA#rID` | Inline review comment | `PATCH repos/OWNER/REPO/pulls/comments/ID` (see below) | | `OWNER/REPO/pull/N#pullrequestreview-ID` | Review summary body | `PUT repos/OWNER/REPO/pulls/N/reviews/ID` | Before editing, the tool reads the comment (`GET`). For a conversation comment or an inline review comment it checks that the comment belongs to the issue or PR `N` in the URL: the comment's `issue_url` must end in `/issues/N` (PR conversation comments also use `/issues/N`), or its `pull_request_url` in `/pulls/N`. A comment of another issue or PR is an error naming both numbers, and nothing is edited. A review summary is read from `GET repos/OWNER/REPO/pulls/N/reviews/ID`, which carries the PR number in its path. A failed read, including a 404 on a conversation comment or a review summary, is an error with gh's message and no edit. -For the two inline review comment forms, a 404 on that read means the comment is not a submitted one, or the token has no access to the repository. The comment can still be in your own unsubmitted (pending) review, which the REST endpoint does not return. The tool then looks up your pending review on PR `N` through GraphQL (among up to 100 pending reviews, only the one you wrote) and matches the comment's `fullDatabaseId`. When your review has more than 100 comments, the tool reads the further pages until it finds the comment or reaches the last page. It edits a match with the `updatePullRequestReviewComment` mutation and returns the comment's `url`. When the lookup finds no match or fails, the error states both facts: `Error: comment_edit: GET repos/OWNER/REPO/pulls/comments/ID returned 404 (not found, or no access to the repository); pending-review lookup on PR N: `. A failed page is a lookup error. So is a PR with more than 100 pending reviews none of which is yours. A mutation that fails is an error that starts with the same 404 statement, followed by `pending-review comment edit failed: ...`; a mutation that succeeds without a URL is the same 404 statement followed by the sent-but-no-URL message described below; pages that never advance (GitHub returns a cursor already used in this lookup) are a lookup error. A read that fails with anything other than a 404 is an error too. A failed read, lookup page, or mutation makes no further call. +For the two inline review comment forms, a 404 on that read means the comment is not a submitted one, or the token has no access to the repository. The comment can still be in your own unsubmitted (pending) review, which the REST endpoint does not return. The tool then reads the pending reviews of PR `N` through GraphQL (the first 100 reviews, each with its first 100 comments) and searches every returned review for the comment's `fullDatabaseId`. When none matches, it reads further comment pages of a review that reports more, up to 50 pages (5,000 comments) in all. It edits a match with the `updatePullRequestReviewComment` mutation and returns the comment's `url`. No author check runs for a match: GitHub refuses to edit another user's comment, and that refusal is the mutation's error. When the lookup finds no match or fails, the error states both facts: `Error: comment_edit: GET repos/OWNER/REPO/pulls/comments/ID returned 404 (not found, or no access to the repository); pending-review lookup on PR N: `. A failed page and reaching the page limit are lookup errors. A mutation that fails is an error that starts with the same 404 statement, followed by `pending-review comment edit failed: ...`; a mutation that succeeds without a URL is the same 404 statement followed by the sent-but-no-URL message described below. A read that fails with anything other than a 404 is an error too. A failed read, lookup page, or mutation makes no further call. -Author check: unless `allow_other_author` is `true`, the tool then compares the `user.login` of the comment or review summary with the authenticated user's login, case-insensitively. The login comes from one GraphQL `viewer { login }` query per call. A different author is an error that names both logins and says to pass `allow_other_author: true`, and nothing is edited. A failed login query, a response without a login, or a comment without an author login is an error and nothing is edited. With `allow_other_author: true` the tool skips the login query and the comparison. A comment found in your own pending review is not checked, because that lookup only covers your own review and sends no login query. +Author check: unless `allow_other_author` is `true`, the tool takes the `node_id` from the read of the comment or review summary and asks GitHub, in one GraphQL query (`node(id: ID) { ... on Comment { viewerDidAuthor } }`), whether the authenticated user wrote it. Any answer other than `viewerDidAuthor: true` (`false`, or no value) is an error that names the author (the read's `user.login`, or "a deleted user" when the read names no login) and says to pass `allow_other_author: true`, and nothing is edited. A read without a `node_id`, or a failed query, is an error and nothing is edited. With `allow_other_author: true` the tool sends no query and skips the check. A comment found through the pending-review lookup is not checked and sends no author query: the lookup searches every pending review it returns for the comment's ID, and GitHub's mutation refuses another author's comment. -Every failure is an error: the output starts with `Error:` and the call fails. When the edit request succeeds but GitHub's response holds no URL, the call is an error too: the message says the edit was sent and GitHub reported success but returned no URL, so the comment may already hold the new body. Output that a successful `gh` call writes to stderr, such as a warning, does not fail the edit. A failed `gh` call returns its stderr message in the error. - -Any other URL fails before the request: no anchor, an unknown anchor, a non-numeric number or ID, a number or ID with a leading zero, `#discussion_rID`, `#rID`, or `#pullrequestreview-ID` on an `issues/N` URL, `#rID` without `/files`, `/changes`, `/files/SHA..SHA`, or `/commits/SHA`, a malformed `SHA` segment, a query string after the `#`, and extra path segments. The repository comes from the URL, so the tool takes no `repo` parameter. +Every failure is returned as a tool error, and its message names what failed. When the edit request succeeds but GitHub's response holds no URL, the call is an error too: the message says the edit was sent and GitHub reported success but returned no URL, so the comment may already hold the new body. Output that a successful `gh` call writes to stderr, such as a warning, does not fail the edit. +Any other URL fails before the request: no anchor, an unknown anchor, a non-numeric number or ID, `#discussion_rID`, `#rID`, or `#pullrequestreview-ID` on an `issues/N` URL, `#rID` without `/files`, `/changes`, `/files/SHA`, `/files/SHA..SHA`, `/changes/SHA`, `/changes/SHA..SHA`, or `/commits/SHA`, a malformed `SHA` segment, a query string after the `#`, and extra path segments. The repository comes from the URL, so the tool takes no `repo` parameter. **Parameters:** - `url` (string, required): URL of the comment to edit, with its anchor. diff --git a/plugins/github-mcp/hooks/scripts/check-gh-tools.sh b/plugins/github-mcp/hooks/scripts/check-gh-tools.sh index 9f142d3..28c9b81 100644 --- a/plugins/github-mcp/hooks/scripts/check-gh-tools.sh +++ b/plugins/github-mcp/hooks/scripts/check-gh-tools.sh @@ -228,6 +228,21 @@ fi if [[ "$BLOCK_API_COMMANDS" == "true" ]]; then + # Editing an existing comment or review body → comment_edit. Checked before + # the reads below, so PUT .../pulls/N/reviews/ID does not land on pr_reviews. + # The method flag may come before or after the endpoint. + if echo "$COMMAND" | grep -qE 'gh\s+api\s.*repos/[^/[:space:]]+/[^/[:space:]]+/(issues|pulls)/comments/[0-9]+([^0-9/]|$)' \ + && echo "$COMMAND" | grep -qE '(-X|--method)(\s+|=)?PATCH([^A-Za-z]|$)'; then + block_tool "mcp__gh-tooling-write__comment_edit" \ + "Use comment_edit with the comment's url and the new body." + fi + + if echo "$COMMAND" | grep -qE 'gh\s+api\s.*repos/[^/[:space:]]+/[^/[:space:]]+/pulls/[0-9]+/reviews/[0-9]+([^0-9/]|$)' \ + && echo "$COMMAND" | grep -qE '(-X|--method)(\s+|=)?PUT([^A-Za-z]|$)'; then + block_tool "mcp__gh-tooling-write__comment_edit" \ + "Use comment_edit with the review's url and the new body." + fi + # PR inline review comments → pr_comments if echo "$COMMAND" | grep -qE 'gh\s+api\s+repos/[^/[:space:]]+/[^/[:space:]]+/pulls/[0-9]+/comments'; then block_tool "mcp__gh-tooling__pr_comments" \ diff --git a/plugins/github-mcp/mcp-server-gh/lib/review_write.sh b/plugins/github-mcp/mcp-server-gh/lib/review_write.sh index ff977d5..c67383f 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/review_write.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/review_write.sh @@ -299,14 +299,17 @@ _comment_edit_graphql() { } ####################################### -# Find a comment in the caller's own pending review on a PR and return its -# GraphQL node ID. Only a review the caller wrote counts (viewerDidAuthor); -# GitHub allows one pending review per author and PR. The first 100 comments -# come with the review lookup, and every further page is fetched until the -# comment matches or the last page ends. fullDatabaseId is a BigInt that -# GitHub serializes as a string. Each response is read by one jq program that -# prints tab-separated fields, with "-" for an empty one so that consecutive -# tabs never collapse. +# Find a comment in the pending reviews of a PR and return its GraphQL node ID. +# Every pending review the lookup returns is searched; GitHub refuses to edit +# another user's comment, and that refusal comes back as the mutation's error. +# The first 100 comments of each review come with the lookup. When none of them +# matches, the comments of the first review with more are read page by page, +# up to 50 pages in all (5,000 comments). A page that reports more but gives no +# cursor counts as the last one. fullDatabaseId is a BigInt that GitHub +# serializes as a string. One jq program reads each response and prints +# "found", a tab, and the node ID; or "more", a tab, the cursor, and (lookup +# only) a tab and the review ID; or "end". A response without the nodes the +# program iterates makes jq fail. # Arguments: # $1 name of the caller's variable that receives the result, # $2 repository in owner/repo format, @@ -321,141 +324,151 @@ _comment_edit_graphql() { ####################################### _comment_edit_find_pending() { local __cfp_var="$1" __cfp_repo="$2" __cfp_number="$3" __cfp_id="$4" - local __cfp_resp __cfp_row __cfp_exit=0 - local pr_ok own_count reviews_next review_id node_id comments_next cursor - local -A used_cursors=() + local __cfp_resp __cfp_row __cfp_exit=0 __cfp_page=1 __cfp_max_pages=50 + local kind value review_id - # shellcheck disable=SC2016 # jq variables ($pr, $id), not shell vars + # shellcheck disable=SC2016 # jq variable ($id), not a shell var local lookup_jq=' - def dash: if . == null or . == "" then "-" else . end; - .data.repository.pullRequest as $pr - | if ($pr | type) != "object" then ["no", "-", "-", "-", "-", "-", "-"] - else - [$pr.reviews.nodes[]? | select(.viewerDidAuthor == true)] as $own - | ($own[0] // {}) as $rev - | ["yes", - ($own | length | tostring), - (($pr.reviews.pageInfo.hasNextPage // false) | tostring), - ($rev.id | dash), - ([$rev.comments.nodes[]? | select(.fullDatabaseId == $id) | .id][0] | dash), - (($rev.comments.pageInfo.hasNextPage // false) | tostring), - ($rev.comments.pageInfo.endCursor | dash)] - end - | @tsv' - # shellcheck disable=SC2016 # jq variables ($c, $id), not shell vars + .data.repository.pullRequest.reviews.nodes as $reviews + | ([$reviews[] | .comments.nodes[] | select(.fullDatabaseId == $id) | .id][0]) as $found + | ([$reviews[] | select(.comments.pageInfo.hasNextPage == true and .comments.pageInfo.endCursor != null)][0]) as $more + | if $found then "found\t\($found)" + elif $more then "more\t\($more.comments.pageInfo.endCursor)\t\($more.id)" + else "end" end' + # shellcheck disable=SC2016 # jq variable ($id), not a shell var local page_jq=' - def dash: if . == null or . == "" then "-" else . end; .data.node.comments as $c - | if ($c | type) != "object" then ["no", "-", "-", "-"] - else - ["yes", - ([$c.nodes[]? | select(.fullDatabaseId == $id) | .id][0] | dash), - (($c.pageInfo.hasNextPage // false) | tostring), - ($c.pageInfo.endCursor | dash)] - end - | @tsv' + | ([$c.nodes[] | select(.fullDatabaseId == $id) | .id][0]) as $found + | if $found then "found\t\($found)" + elif $c.pageInfo.hasNextPage == true and $c.pageInfo.endCursor != null then "more\t\($c.pageInfo.endCursor)" + else "end" end' _comment_edit_graphql __cfp_resp gh api graphql \ - -f "query=query(\$owner: String!, \$name: String!, \$number: Int!) { repository(owner: \$owner, name: \$name) { pullRequest(number: \$number) { reviews(states: [PENDING], first: 100) { pageInfo { hasNextPage } nodes { id viewerDidAuthor comments(first: 100) { nodes { id fullDatabaseId } pageInfo { hasNextPage endCursor } } } } } } }" \ + -f "query=query(\$owner: String!, \$name: String!, \$number: Int!) { repository(owner: \$owner, name: \$name) { pullRequest(number: \$number) { reviews(states: [PENDING], first: 100) { nodes { id comments(first: 100) { nodes { id fullDatabaseId } pageInfo { hasNextPage endCursor } } } } } } }" \ -f "owner=${__cfp_repo%%/*}" -f "name=${__cfp_repo##*/}" -F "number=${__cfp_number}" || __cfp_exit=$? if [[ ${__cfp_exit} -ne 0 ]]; then printf -v "${__cfp_var}" '%s' "${__cfp_resp}" return "${__cfp_exit}" fi if ! __cfp_row=$(printf '%s' "${__cfp_resp}" | jq -r --arg id "${__cfp_id}" "${lookup_jq}" 2>/dev/null); then - printf -v "${__cfp_var}" '%s' "unreadable response: ${__cfp_resp}" + printf -v "${__cfp_var}" '%s' "unexpected response from GitHub for the pending reviews" return 1 fi - IFS=$'\t' read -r pr_ok own_count reviews_next review_id node_id comments_next cursor <<< "${__cfp_row}" + IFS=$'\t' read -r kind value review_id <<< "${__cfp_row}" - if [[ "${pr_ok}" != "yes" ]]; then - printf -v "${__cfp_var}" '%s' "response has no pull request data: ${__cfp_resp}" - return 1 - fi - if [[ "${own_count}" == "0" ]]; then - if [[ "${reviews_next}" == "true" ]]; then - printf -v "${__cfp_var}" '%s' "your pending review could not be located among more than 100 pending reviews" - else - printf -v "${__cfp_var}" '%s' "no matching comment (you have no pending review on this PR)" - fi - return 1 - fi - - while [[ "${node_id}" == "-" && "${comments_next}" == "true" ]]; do - if [[ "${review_id}" == "-" || "${cursor}" == "-" ]]; then - printf -v "${__cfp_var}" '%s' "your pending review has more comments but GitHub gave no cursor to read them: ${__cfp_resp}" + while [[ "${kind}" == "more" ]]; do + if [[ ${__cfp_page} -ge ${__cfp_max_pages} ]]; then + printf -v "${__cfp_var}" '%s' "reached the limit of ${__cfp_max_pages} comment pages without finding comment ${__cfp_id}" return 1 fi + __cfp_page=$((__cfp_page + 1)) log "INFO" "comment_edit: next page of comments in pending review ${review_id}" - used_cursors["${cursor}"]=1 __cfp_exit=0 _comment_edit_graphql __cfp_resp gh api graphql \ -f "query=query(\$reviewId: ID!, \$cursor: String!) { node(id: \$reviewId) { ... on PullRequestReview { comments(first: 100, after: \$cursor) { nodes { id fullDatabaseId } pageInfo { hasNextPage endCursor } } } } }" \ - -f "reviewId=${review_id}" -f "cursor=${cursor}" || __cfp_exit=$? + -f "reviewId=${review_id}" -f "cursor=${value}" || __cfp_exit=$? if [[ ${__cfp_exit} -ne 0 ]]; then printf -v "${__cfp_var}" '%s' "reading the next comments of your pending review failed: ${__cfp_resp}" return "${__cfp_exit}" fi if ! __cfp_row=$(printf '%s' "${__cfp_resp}" | jq -r --arg id "${__cfp_id}" "${page_jq}" 2>/dev/null); then - printf -v "${__cfp_var}" '%s' "unreadable response: ${__cfp_resp}" - return 1 - fi - IFS=$'\t' read -r pr_ok node_id comments_next cursor <<< "${__cfp_row}" - if [[ "${pr_ok}" != "yes" ]]; then - printf -v "${__cfp_var}" '%s' "response has no comments for your pending review: ${__cfp_resp}" - return 1 - fi - if [[ "${node_id}" == "-" && "${comments_next}" == "true" && -n "${used_cursors[${cursor}]:-}" ]]; then - printf -v "${__cfp_var}" '%s' "the comment pages of your pending review did not advance (cursor ${cursor} came back): ${__cfp_resp}" + printf -v "${__cfp_var}" '%s' "unexpected response from GitHub for comment page ${__cfp_page} of your pending review" return 1 fi + IFS=$'\t' read -r kind value <<< "${__cfp_row}" done - if [[ "${node_id}" == "-" ]]; then - printf -v "${__cfp_var}" '%s' "no matching comment (it is not in your pending review)" + if [[ "${kind}" != "found" ]]; then + printf -v "${__cfp_var}" '%s' "no matching comment in your pending review" return 1 fi - printf -v "${__cfp_var}" '%s' "${node_id}" + printf -v "${__cfp_var}" '%s' "${value}" } ####################################### -# Refuse to edit a comment somebody else wrote. The author comes from the -# response of the read that precedes the write; the caller's login comes from -# one GraphQL viewer query. Logins compare case-insensitively. +# Refuse to edit a comment somebody else wrote. GitHub answers whether the +# authenticated user wrote it: the comment's node_id, from the response of the +# read that precedes the write, goes into one GraphQL query for viewerDidAuthor +# (IssueComment, PullRequestReviewComment, and PullRequestReview all implement +# the Comment interface). The login in the read's response only names the +# author in the refusal. # Arguments: # $1 response of the GET of the comment or review summary, # $2 the GET's endpoint, for the message. # Outputs: -# An "Error: ..." message on stdout when the author cannot be read, the -# login lookup fails, or the logins differ. +# An "Error: ..." message on stdout when the node ID is missing, the query +# fails, or the answer is not exactly true. # Returns: -# 0 when the caller wrote it; otherwise gh's exit status from the login -# lookup, or 1. +# 0 when the caller wrote it; otherwise gh's exit status from the query, or 1. ####################################### _comment_edit_check_author() { local response="$1" endpoint="$2" - local author viewer_out viewer viewer_exit=0 + local node_id author author_out author_exit=0 - if ! author=$(printf '%s' "${response}" | jq -r '.user.login // empty' 2>/dev/null) || [[ -z "${author}" ]]; then - echo "Error: comment_edit: GET ${endpoint} returned no author login, so it cannot be compared with yours; nothing was edited. Pass allow_other_author: true to edit it anyway: ${response}" + if ! node_id=$(printf '%s' "${response}" | jq -r '.node_id // empty' 2>/dev/null) || [[ -z "${node_id}" ]]; then + echo "Error: comment_edit: unexpected response from GitHub for GET ${endpoint}" return 1 fi - log "INFO" "comment_edit: reading the authenticated login" - _comment_edit_graphql viewer_out gh api graphql -f 'query=query { viewer { login } }' || viewer_exit=$? - if [[ ${viewer_exit} -ne 0 ]]; then - echo "Error: comment_edit: reading your login failed: ${viewer_out}; nothing was edited" - return "${viewer_exit}" + log "INFO" "comment_edit: asking whether you wrote ${node_id}" + # shellcheck disable=SC2016 # GraphQL variable ($id), not a shell var + _comment_edit_graphql author_out gh api graphql \ + -f 'query=query($id: ID!) { node(id: $id) { ... on Comment { viewerDidAuthor } } }' \ + -f "id=${node_id}" || author_exit=$? + if [[ ${author_exit} -ne 0 ]]; then + echo "Error: comment_edit: asking whether you wrote the comment failed: ${author_out}; nothing was edited" + return "${author_exit}" fi - if ! viewer=$(printf '%s' "${viewer_out}" | jq -r '.data.viewer.login // empty' 2>/dev/null) || [[ -z "${viewer}" ]]; then - echo "Error: comment_edit: the response to the login query holds no login; nothing was edited: ${viewer_out}" + if ! printf '%s' "${author_out}" | jq -e '.data.node.viewerDidAuthor == true' >/dev/null 2>&1; then + author=$(printf '%s' "${response}" | jq -r '.user | objects | .login | strings') + echo "Error: comment_edit: the comment was written by ${author:-a deleted user}, not by you; nothing was edited. Pass allow_other_author: true to edit it" return 1 fi +} - if [[ "${author,,}" != "${viewer,,}" ]]; then - echo "Error: comment_edit: the comment was written by ${author}, not by you (${viewer}); nothing was edited. Pass allow_other_author: true to edit it" +####################################### +# Edit an inline comment that the REST API reported as 404 and that may sit in +# the caller's own pending review: find it there, then edit it through the +# updatePullRequestReviewComment mutation. No author check runs: GitHub refuses +# to edit another user's comment, and that refusal is the mutation's error. +# Every error starts with the 404 statement. +# Arguments: +# $1 repository in owner/repo format, +# $2 PR number, +# $3 database ID of the comment, +# $4 new body, +# $5 the statement that the REST read returned 404. +# Outputs: +# The edited comment's URL on success, otherwise an "Error: ..." message. +# Returns: +# 0 when the comment was edited; otherwise gh's exit status, or 1 when the +# failure did not come from a gh exit. +####################################### +_comment_edit_pending_edit() { + local repo="$1" number="$2" comment_id="$3" body="$4" not_found_note="$5" + local pending_node_id mutation mutation_url __exit=0 + + log "INFO" "comment_edit: pending-review lookup for comment ${comment_id} on PR ${number}" + _comment_edit_find_pending pending_node_id "${repo}" "${number}" "${comment_id}" || __exit=$? + if [[ ${__exit} -ne 0 ]]; then + echo "Error: comment_edit: ${not_found_note}; pending-review lookup on PR ${number}: ${pending_node_id}" + return "${__exit}" + fi + + log "INFO" "comment_edit: pending-review mutation for ${pending_node_id}" + _comment_edit_graphql mutation gh api graphql \ + -f "query=mutation(\$id: ID!, \$body: String!) { updatePullRequestReviewComment(input: {pullRequestReviewCommentId: \$id, body: \$body}) { pullRequestReviewComment { url } } }" \ + -f "id=${pending_node_id}" -f "body=${body}" || __exit=$? + if [[ ${__exit} -ne 0 ]]; then + echo "Error: comment_edit: ${not_found_note}; pending-review comment edit failed: ${mutation}" + return "${__exit}" + fi + mutation_url=$(printf '%s' "${mutation}" | jq -r '.data.updatePullRequestReviewComment.pullRequestReviewComment.url // empty' 2>/dev/null || true) + if [[ -z "${mutation_url}" ]]; then + echo "Error: comment_edit: ${not_found_note}; the pending-review edit was sent and GitHub reported success but returned no URL, so the comment may already hold the new body: ${mutation}" return 1 fi + echo "${mutation_url}" } ####################################### @@ -469,9 +482,9 @@ _comment_edit_check_author() { # true, the edit also needs the comment's author to be the caller. An inline # comment the REST API reports as 404 may sit in the caller's own pending # review, so it is looked up there, page by page, and edited through GraphQL; -# that review is the caller's by the lookup, so no author check runs. When that -# lookup fails or finds nothing, the error states the 404 and the lookup's -# result. Every other failure ends the call without a write. +# no author check runs there, because GitHub refuses to edit another user's +# comment. When that lookup fails or finds nothing, the error states the 404 +# and the lookup's result. Every other failure ends the call without a write. # Arguments: # $1 JSON arguments: url (required), body (required), allow_other_author # (optional boolean, default false). @@ -499,33 +512,33 @@ tool_comment_edit() { return 1 fi - local rest="${url}" - case "${rest}" in - https://github.com/*) rest="${rest#https://github.com/}" ;; - http://github.com/*) rest="${rest#http://github.com/}" ;; - https://www.github.com/*) rest="${rest#https://www.github.com/}" ;; - esac + # The scheme and host match without regard to case; the path keeps its case. + local rest="${url}" prefix + for prefix in "https://github.com/" "http://github.com/" "https://www.github.com/"; do + if [[ "${rest,,}" == "${prefix}"* ]]; then + rest="${rest:${#prefix}}" + break + fi + done if [[ "${rest}" =~ ^[A-Za-z][A-Za-z0-9+.-]*:// ]]; then echo "Error: url must be a github.com URL, got: '${url}'" return 1 fi - # Owners may contain "_" (Enterprise Managed User accounts). GitHub never - # generates a number or ID with a leading zero, so none is accepted. - # _gh_validate_repo and _gh_validate_number accept an owner with "." and a - # leading zero or 0, which this tool rejects, so the patterns below stay. - # A "?query" before the "#" is dropped. + # Owners may contain "_" (Enterprise Managed User accounts). A "?query" + # before the "#" is dropped. local sha='[0-9a-fA-F]{7,40}' - local form_error="Error: url must be OWNER/REPO/issues/N#issuecomment-ID, OWNER/REPO/pull/N#issuecomment-ID, OWNER/REPO/pull/N#discussion_rID, OWNER/REPO/pull/N/files#rID, OWNER/REPO/pull/N/changes#rID, OWNER/REPO/pull/N/files/SHA..SHA#rID, OWNER/REPO/pull/N/commits/SHA#rID, or OWNER/REPO/pull/N#pullrequestreview-ID (SHA is 7 to 40 hex characters; optionally with a leading https://github.com/ and a ?query before the #), got: '${url}'" - local url_re="^([A-Za-z0-9_-]+/[A-Za-z0-9_.-]+)/(issues|pull)/([1-9][0-9]*)(/files|/changes|/files/${sha}\\.\\.${sha}|/commits/${sha})?(\\?[^#]*)?#([A-Za-z0-9_-]+)\$" + local form_error="Error: url must be OWNER/REPO/issues/N#issuecomment-ID, OWNER/REPO/pull/N#issuecomment-ID, OWNER/REPO/pull/N#discussion_rID, OWNER/REPO/pull/N/files#rID, OWNER/REPO/pull/N/changes#rID, OWNER/REPO/pull/N/files/SHA#rID, OWNER/REPO/pull/N/files/SHA..SHA#rID, OWNER/REPO/pull/N/changes/SHA#rID, OWNER/REPO/pull/N/changes/SHA..SHA#rID, OWNER/REPO/pull/N/commits/SHA#rID, or OWNER/REPO/pull/N#pullrequestreview-ID (SHA is 7 to 40 hex characters; optionally with a leading https://github.com/ and a ?query before the #), got: '${url}'" + local url_re="^([A-Za-z0-9_-]+/[A-Za-z0-9_.-]+)/(issues|pull)/([0-9]+)(/files|/changes|/files/${sha}\\.\\.${sha}|/files/${sha}|/changes/${sha}\\.\\.${sha}|/changes/${sha}|/commits/${sha})?(\\?[^#]*)?#([A-Za-z0-9_-]+)\$" if [[ ! "${rest}" =~ ${url_re} ]]; then echo "${form_error}" return 1 fi local repo="${BASH_REMATCH[1]}" kind="${BASH_REMATCH[2]}" number="${BASH_REMATCH[3]}" local suffix="${BASH_REMATCH[4]}" anchor="${BASH_REMATCH[6]}" - if [[ "${repo##*/}" == "." || "${repo##*/}" == ".." || "${repo##*/}" == *.git ]]; then + # A repo named "." or ".." would let repos/OWNER/../... resolve to another endpoint. + if [[ "${repo##*/}" == "." || "${repo##*/}" == ".." ]]; then echo "${form_error}" return 1 fi @@ -534,18 +547,18 @@ tool_comment_edit() { # PR the comment belongs to; owner_path is how that URL must end. A review # summary has neither: its endpoint already carries the PR number. local endpoint method owner_field="" owner_path="" review_comment_id="" - if [[ "${anchor}" =~ ^issuecomment-([1-9][0-9]*)$ && -z "${suffix}" ]]; then + if [[ "${anchor}" =~ ^issuecomment-([0-9]+)$ && -z "${suffix}" ]]; then endpoint="repos/${repo}/issues/comments/${BASH_REMATCH[1]}" method="PATCH" owner_field="issue_url" owner_path="issues/${number}" - elif [[ "${kind}" == "pull" ]] && { [[ -z "${suffix}" && "${anchor}" =~ ^discussion_r([1-9][0-9]*)$ ]] || [[ -n "${suffix}" && "${anchor}" =~ ^r([1-9][0-9]*)$ ]]; }; then + elif [[ "${kind}" == "pull" ]] && { [[ -z "${suffix}" && "${anchor}" =~ ^discussion_r([0-9]+)$ ]] || [[ -n "${suffix}" && "${anchor}" =~ ^r([0-9]+)$ ]]; }; then endpoint="repos/${repo}/pulls/comments/${BASH_REMATCH[1]}" method="PATCH" owner_field="pull_request_url" owner_path="pulls/${number}" review_comment_id="${BASH_REMATCH[1]}" - elif [[ "${kind}" == "pull" && -z "${suffix}" && "${anchor}" =~ ^pullrequestreview-([1-9][0-9]*)$ ]]; then + elif [[ "${kind}" == "pull" && -z "${suffix}" && "${anchor}" =~ ^pullrequestreview-([0-9]+)$ ]]; then endpoint="repos/${repo}/pulls/${number}/reviews/${BASH_REMATCH[1]}" method="PUT" else @@ -554,7 +567,6 @@ tool_comment_edit() { fi local __out __err __exit=0 - local not_found_note="" log "INFO" "comment_edit: GET ${endpoint}" _comment_edit_gh __out __err gh api "${endpoint}" || __exit=$? @@ -562,56 +574,27 @@ tool_comment_edit() { # Only an inline comment can still be edited: REST answers 404 for a # comment inside the caller's unsubmitted review. if [[ -n "${review_comment_id}" && "${__err}" == *"(HTTP 404)"* ]]; then - not_found_note="GET ${endpoint} returned 404 (not found, or no access to the repository)" - __exit=0 - else - echo "Error: comment_edit: GET ${endpoint} failed: ${__out}" - return "${__exit}" - fi - else - if [[ -n "${owner_field}" ]]; then - local owner_url - if ! owner_url=$(printf '%s' "${__out}" | jq -r --arg field "${owner_field}" '.[$field] // empty' 2>/dev/null) || [[ -z "${owner_url}" ]]; then - echo "Error: comment_edit: GET ${endpoint} returned no ${owner_field}: ${__out}" - return 1 - fi - if [[ "${owner_url}" != */"${owner_path}" ]]; then - echo "Error: comment_edit: the comment belongs to #${owner_url##*/}, not to #${number} named in the URL; nothing was edited" - return 1 - fi - fi - if [[ "${allow_other_author}" != "true" ]]; then - _comment_edit_check_author "${__out}" "${endpoint}" || return $? + _comment_edit_pending_edit "${repo}" "${number}" "${review_comment_id}" "${body}" \ + "GET ${endpoint} returned 404 (not found, or no access to the repository)" + return $? fi + echo "Error: comment_edit: GET ${endpoint} failed: ${__out}" + return "${__exit}" fi - if [[ -n "${not_found_note}" ]]; then - local pending_node_id mutation mutation_url - log "INFO" "comment_edit: pending-review lookup for comment ${review_comment_id} on PR ${number}" - _comment_edit_find_pending pending_node_id "${repo}" "${number}" "${review_comment_id}" || __exit=$? - if [[ ${__exit} -ne 0 ]]; then - echo "Error: comment_edit: ${not_found_note}; pending-review lookup on PR ${number}: ${pending_node_id}" - return "${__exit}" - fi - - log "INFO" "comment_edit: pending-review mutation for ${pending_node_id}" - _comment_edit_graphql mutation gh api graphql \ - -f "query=mutation(\$id: ID!, \$body: String!) { updatePullRequestReviewComment(input: {pullRequestReviewCommentId: \$id, body: \$body}) { pullRequestReviewComment { url } } }" \ - -f "id=${pending_node_id}" -f "body=${body}" || __exit=$? - if [[ ${__exit} -ne 0 ]]; then - echo "Error: comment_edit: ${not_found_note}; pending-review comment edit failed: ${mutation}" - return "${__exit}" - fi - if ! mutation_url=$(printf '%s' "${mutation}" | jq -r '.data.updatePullRequestReviewComment.pullRequestReviewComment.url // empty' 2>/dev/null); then - echo "Error: comment_edit: ${not_found_note}; pending-review comment edit failed: unreadable response: ${mutation}" + if [[ -n "${owner_field}" ]]; then + local owner_url + if ! owner_url=$(printf '%s' "${__out}" | jq -r --arg field "${owner_field}" '.[$field] // empty' 2>/dev/null) || [[ -z "${owner_url}" ]]; then + echo "Error: comment_edit: GET ${endpoint} returned no ${owner_field}; nothing was edited" return 1 fi - if [[ -z "${mutation_url}" ]]; then - echo "Error: comment_edit: ${not_found_note}; the pending-review edit was sent and GitHub reported success but returned no URL, so the comment may already hold the new body: ${mutation}" + if [[ "${owner_url}" != */"${owner_path}" ]]; then + echo "Error: comment_edit: the comment belongs to #${owner_url##*/}, not to #${number} named in the URL; nothing was edited" return 1 fi - echo "${mutation_url}" - return 0 + fi + if [[ "${allow_other_author}" != "true" ]]; then + _comment_edit_check_author "${__out}" "${endpoint}" || return $? fi log "INFO" "comment_edit: ${method} ${endpoint}" diff --git a/plugins/github-mcp/mcp-server-gh/tools-write.json b/plugins/github-mcp/mcp-server-gh/tools-write.json index 898e105..2afb237 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-write.json +++ b/plugins/github-mcp/mcp-server-gh/tools-write.json @@ -757,7 +757,7 @@ }, { "name": "comment_edit", - "description": "Replace the body of an issue or pull request comment, an inline comment on a diff (including one in your own unsubmitted draft), or a summary of a submitted verdict. The new body replaces the whole text. Accepted url forms, with no prefix or with a leading https://github.com/, http://github.com/, or https://www.github.com/: OWNER/REPO/issues/N#issuecomment-ID, OWNER/REPO/pull/N#issuecomment-ID, OWNER/REPO/pull/N#discussion_rID, OWNER/REPO/pull/N/files#rID (or /changes#rID, /files/SHA..SHA#rID, /commits/SHA#rID), OWNER/REPO/pull/N#pullrequestreview-ID; a ?query before the # is ignored. Any other url, including any other host, is rejected before the request. A comment written by someone else is refused unless allow_other_author is true. Returns the edited comment's URL.", + "description": "Replace the body of an issue or pull request comment, an inline comment on a diff (including one in your own unsubmitted draft), or a summary of a submitted verdict. The new body replaces the whole text. Accepted url forms, with no prefix or with a leading https://github.com/, http://github.com/, or https://www.github.com/: OWNER/REPO/issues/N#issuecomment-ID, OWNER/REPO/pull/N#issuecomment-ID, OWNER/REPO/pull/N#discussion_rID, OWNER/REPO/pull/N/files#rID (or /changes#rID, /files/SHA#rID, /files/SHA..SHA#rID, /changes/SHA#rID, /changes/SHA..SHA#rID, /commits/SHA#rID), OWNER/REPO/pull/N#pullrequestreview-ID; a ?query before the # is ignored. Any other url, including any other host, is rejected before the request. A comment written by someone else is refused unless allow_other_author is true. Returns the edited comment's URL.", "inputSchema": { "type": "object", "required": ["url", "body"], diff --git a/plugins/plugin-setup/CHANGELOG.md b/plugins/plugin-setup/CHANGELOG.md index 5f4d36c..f965dad 100644 --- a/plugins/plugin-setup/CHANGELOG.md +++ b/plugins/plugin-setup/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- The setup reference (`references/plugin-setup.md`) now includes the `comment_edit` permission + pattern, and the description of its "PR reviews and comments" group now mentions editing an + existing comment. + ## [1.0.0] - 2026-07-13 ### Added