diff --git a/README.md b/README.md index 69b8b0c..ce25a41 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The read server (`gh-tooling`) is always active. The write server (`gh-tooling-w | Component | Description | |------------|------------------------------------------------------------------------------------------------------| -| 🔌 MCP | Two servers — `gh-tooling` (30 read tools) and `gh-tooling-write` (23 write tools, gated) | +| 🔌 MCP | Two servers — `gh-tooling` (31 read tools) and `gh-tooling-write` (25 write tools, gated) | | 🪝 Hooks | SessionStart directive + PreToolUse enforcement that redirects `gh` bash calls to the MCP tools | See [plugins/github-mcp/README.md](./plugins/github-mcp/README.md) for full configuration, the complete tool reference, and troubleshooting. See [plugins/github-mcp/REFERENCE.md](./plugins/github-mcp/REFERENCE.md) for per-tool parameter docs. diff --git a/plugin-tests/github-mcp/check_api_tools.bats b/plugin-tests/github-mcp/check_api_tools.bats index ba83a84..d39e4de 100644 --- a/plugin-tests/github-mcp/check_api_tools.bats +++ b/plugin-tests/github-mcp/check_api_tools.bats @@ -66,6 +66,27 @@ setup_read_blocking() { assert_output --partial "repo_tree" } +@test "read api: blocks GET issues/N/issue-field-values → suggests issue_view" { + setup_read_blocking + run_api_hook "$READ_TOOL" "repos/shopware/shopware/issues/123/issue-field-values" + assert_failure 2 + assert_output --partial "issue_view" +} + +@test "read api: blocks orgs/N/issue-types → suggests issue_schema" { + setup_read_blocking + run_api_hook "$READ_TOOL" "orgs/shopware/issue-types" + assert_failure 2 + assert_output --partial "issue_schema" +} + +@test "read api: blocks orgs/N/issue-fields → suggests issue_schema" { + setup_read_blocking + run_api_hook "$READ_TOOL" "orgs/shopware/issue-fields" + assert_failure 2 + assert_output --partial "issue_schema" +} + @test "read api: allows unknown endpoint" { setup_read_blocking run_api_hook "$READ_TOOL" "repos/shopware/shopware/actions/runs/123/jobs" @@ -133,6 +154,42 @@ setup_write_blocking() { assert_output --partial "issue_comment" } +@test "write api: blocks PUT issues/N/issue-field-values → suggests issue_field_set" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/issues/123/issue-field-values" "PUT" + assert_failure 2 + assert_output --partial "issue_field_set" +} + +@test "write api: blocks DELETE issues/N/issue-field-values/M → suggests issue_field_set" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/issues/123/issue-field-values/8847" "DELETE" + assert_failure 2 + assert_output --partial "issue_field_set" +} + +@test "write api: blocks PATCH issues/N → suggests issue_edit or issue_type_set" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/issues/123" "PATCH" + assert_failure 2 + assert_output --partial "issue_type_set" +} + +@test "write api: blocks PATCH issues/N with a query string" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/issues/123?foo=bar" "PATCH" + assert_failure 2 + assert_output --partial "issue_type_set" +} + +@test "write api: a GET of issue-field-values goes to issue_view, not issue_field_set" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/issues/123/issue-field-values" "GET" + assert_failure 2 + assert_output --partial "issue_view" + refute_output --partial "issue_field_set" +} + @test "write api: blocks POST pulls/N/reviews → suggests pr_review_submit" { setup_write_blocking run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/pulls/123/reviews" "POST" diff --git a/plugin-tests/github-mcp/read_tools_issue_schema.bats b/plugin-tests/github-mcp/read_tools_issue_schema.bats new file mode 100644 index 0000000..d9a5efa --- /dev/null +++ b/plugin-tests/github-mcp/read_tools_issue_schema.bats @@ -0,0 +1,165 @@ +#!/usr/bin/env bats +# bats file_tags=github-mcp,read-tools +# Tests for the issue_schema read tool +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +TYPES_JSON='[{"id":125714,"name":"Bug","description":"Something broke","color":"red","is_enabled":true},{"id":25328944,"name":"Improvement","description":"Better now","color":"green","is_enabled":true}]' +FIELDS_JSON='[{"id":8847,"name":"Priority","description":"How urgent","data_type":"single_select","visibility":"all","options":[{"id":12296,"name":"High","color":"red"},{"id":12298,"name":"Low","color":"green"}]},{"id":8848,"name":"Start date","description":"When work begins","data_type":"date","visibility":"organization_members_only"}]' + +setup() { + log() { :; } + GH_DEFAULT_REPO="shopware/shopware" + GH_TOOLING_CONFIG_FILE="" + source "${GH_LIB_DIR}/common.sh" + source "${GH_LIB_DIR}/issue_schema.sh" + + GH_ARGS_FILE="${BATS_TEST_TMPDIR}/gh_args" + + # Stub responds per endpoint: the tool makes two gh api calls per run. + gh() { + printf '%s\n' "$@" >> "${GH_ARGS_FILE}" + case "$*" in + *issue-types*) + [[ -n "${GH_STUB_TYPES_EXIT:-}" ]] && return "${GH_STUB_TYPES_EXIT}" + printf '%s\n' "${GH_STUB_TYPES}" + ;; + *issue-fields*) + [[ -n "${GH_STUB_FIELDS_EXIT:-}" ]] && return "${GH_STUB_FIELDS_EXIT}" + printf '%s\n' "${GH_STUB_FIELDS}" + ;; + *) + printf '%s\n' "${GH_STUB_REPO_VIEW:-}" + ;; + esac + return 0 + } + GH_STUB_TYPES="${TYPES_JSON}" + GH_STUB_FIELDS="${FIELDS_JSON}" + GH_STUB_TYPES_EXIT="" + GH_STUB_FIELDS_EXIT="" +} + +@test "issue_schema returns types and fields for the default repo owner" { + run tool_issue_schema '{}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.org')" "shopware" + assert_equal "$(printf '%s' "${output}" | jq -r '[.types[].name] | join(",")')" "Bug,Improvement" + assert_equal "$(printf '%s' "${output}" | jq -r '[.fields[].name] | join(",")')" "Priority,Start date" +} + +@test "issue_schema queries the organization endpoints" { + run tool_issue_schema '{"org": "shopware"}' + assert_success + run grep -x -- 'orgs/shopware/issue-types' "${GH_ARGS_FILE}" + assert_success + run grep -x -- 'orgs/shopware/issue-fields' "${GH_ARGS_FILE}" + assert_success +} + +@test "issue_schema keeps single-select options and omits them for other data types" { + run tool_issue_schema '{"field": "Priority"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '[.fields[0].options[].name] | join(",")')" "High,Low" + + run tool_issue_schema '{"field": "Start date"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.fields[0] | has("options")')" "false" +} + +@test "issue_schema type filter matches case-insensitively and narrows only types" { + run tool_issue_schema '{"type": "bug"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '[.types[].name] | join(",")')" "Bug" + assert_equal "$(printf '%s' "${output}" | jq -r '.fields | length')" "2" +} + +@test "issue_schema derives the org from a repo parameter" { + run tool_issue_schema '{"repo": "some-other-org/some-repo"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.org')" "some-other-org" +} + +@test "issue_schema errors on an unknown type instead of returning an empty list" { + run tool_issue_schema '{"type": "Nope"}' + assert_failure + assert_output --partial "issue type 'Nope' not found" +} + +@test "issue_schema errors on an unknown field instead of returning an empty list" { + run tool_issue_schema '{"field": "Nope"}' + assert_failure + assert_output --partial "issue field 'Nope' not found" +} + +@test "issue_schema propagates a failing types call" { + GH_STUB_TYPES_EXIT=1 + run tool_issue_schema '{"org": "cli"}' + assert_failure +} + +@test "issue_schema propagates a failing fields call" { + GH_STUB_FIELDS_EXIT=1 + run tool_issue_schema '{"org": "cli"}' + assert_failure +} + +@test "issue_schema returns the fallback when a call fails" { + GH_STUB_TYPES_EXIT=1 + run tool_issue_schema '{"org": "cli", "fallback": "no schema"}' + assert_success + assert_output "no schema" +} + +@test "issue_schema applies jq_filter to the merged document" { + run tool_issue_schema '{"jq_filter": "[.fields[].name]"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r 'join(",")')" "Priority,Start date" +} + +@test "issue_schema rejects an invalid jq_filter" { + run tool_issue_schema '{"jq_filter": "[.fields["}' + assert_failure + assert_output --partial "Invalid jq_filter" +} + +@test "issue_schema prefers org over owner and a repo parameter" { + run tool_issue_schema '{"org": "from-org", "owner": "from-owner", "repo": "from-repo/x"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.org')" "from-org" +} + +@test "issue_schema prefers owner over a repo parameter" { + run tool_issue_schema '{"owner": "from-owner", "repo": "from-repo/x"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.org')" "from-owner" +} + +@test "issue_schema with suppress_errors returns no error text" { + GH_STUB_TYPES_EXIT=1 + run tool_issue_schema '{"org": "cli", "suppress_errors": true}' + assert_failure + assert_output "" +} + +@test "issue_schema keeps working when a field carries a null options key" { + GH_STUB_FIELDS='[{"id":1,"name":"Priority","data_type":"single_select","options":null}]' + run tool_issue_schema '{"org": "acme"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.fields[0] | has("options")')" "false" +} + +@test "issue_schema rejects an org that is not a login" { + run tool_issue_schema '{"org": "../../repos/victim/private"}' + assert_failure + assert_output --partial "invalid organization" + [ ! -f "${GH_ARGS_FILE}" ] +} + +@test "fallback does not mask an unresolvable org" { + GH_DEFAULT_REPO="" + run tool_issue_schema '{"org": "!!", "fallback": "ok"}' + assert_failure + assert_output --partial "invalid organization" +} diff --git a/plugin-tests/github-mcp/read_tools_issue_view_fields.bats b/plugin-tests/github-mcp/read_tools_issue_view_fields.bats new file mode 100644 index 0000000..96c1a44 --- /dev/null +++ b/plugin-tests/github-mcp/read_tools_issue_view_fields.bats @@ -0,0 +1,165 @@ +#!/usr/bin/env bats +# bats file_tags=github-mcp,read-tools +# Tests for issue_view's with_field_values output +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +REST_ISSUE='{"number":19952,"type":{"id":125714,"name":"Bug"},"issue_field_values":[{"issue_field_id":8847,"issue_field_name":"Priority","data_type":"single_select","value":12298,"single_select_option":{"id":12298,"name":"Low","color":"green"}},{"issue_field_id":8849,"issue_field_name":"Target date","data_type":"date","value":"2026-03-01"},{"issue_field_id":8851,"issue_field_name":"Areas","data_type":"multi_select","multi_select_options":[{"id":13001,"name":"Storefront"},{"id":13002,"name":"Admin"}]}]}' + +setup() { + log() { :; } + GH_DEFAULT_REPO="shopware/shopware" + GH_TOOLING_CONFIG_FILE="" + source "${GH_LIB_DIR}/common.sh" + source "${GH_LIB_DIR}/issue.sh" + + GH_ARGS_FILE="${BATS_TEST_TMPDIR}/gh_args" + + # The tool makes at most two calls per run: gh issue view for the requested + # fields, gh api for the type and field values. + gh() { + printf '%s\n' "$*" >> "${GH_ARGS_FILE}" + case "$*" in + *"issue view"*) + [[ -n "${GH_STUB_VIEW_EXIT:-}" ]] && return "${GH_STUB_VIEW_EXIT}" + printf '%s\n' "${GH_STUB_VIEW}" + ;; + *"api repos/"*) + [[ -n "${GH_STUB_REST_EXIT:-}" ]] && { printf '%s\n' "${GH_STUB_REST}"; return "${GH_STUB_REST_EXIT}"; } + printf '%s\n' "${GH_STUB_REST}" + ;; + *) + printf '%s\n' "" + ;; + esac + return 0 + } + GH_STUB_VIEW='{"title":"Broken thing","state":"OPEN"}' + GH_STUB_REST="${REST_ISSUE}" + GH_STUB_VIEW_EXIT="" + GH_STUB_REST_EXIT="" +} + +@test "issue_view with_field_values returns the type and field values keyed by name" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.type')" "Bug" + assert_equal "$(printf '%s' "${output}" | jq -r '.field_values.Priority')" "Low" + assert_equal "$(printf '%s' "${output}" | jq -r '.field_values["Target date"]')" "2026-03-01" + assert_equal "$(printf '%s' "${output}" | jq -rc '.field_values.Areas')" '["Storefront","Admin"]' +} + +@test "issue_view with_field_values reports a single-select by option name, not id" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true}' + assert_success + refute_output --partial "12298" +} + +@test "issue_view with_field_values skips gh issue view when no fields are requested" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true}' + assert_success + run grep -c -- "issue view" "${GH_ARGS_FILE}" + assert_output "0" +} + +@test "issue_view with_field_values merges into the requested fields" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true, "fields": "title,state"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.title')" "Broken thing" + assert_equal "$(printf '%s' "${output}" | jq -r '.state')" "OPEN" + assert_equal "$(printf '%s' "${output}" | jq -r '.type')" "Bug" + assert_equal "$(printf '%s' "${output}" | jq -r '.field_values.Priority')" "Low" +} + +@test "issue_view with_field_values queries the issue's REST endpoint" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true}' + assert_success + run grep -F -- "api repos/shopware/shopware/issues/19952" "${GH_ARGS_FILE}" + assert_success +} + +@test "issue_view with_field_values reports an empty object for an issue with no values" { + GH_STUB_REST='{"number":42,"type":null}' + run tool_issue_view '{"number": 42, "repo": "shopware/shopware", "with_field_values": true}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.type')" "null" + assert_equal "$(printf '%s' "${output}" | jq -rc '.field_values')" "{}" +} + +@test "issue_view rejects with_field_values together with with_comments" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true, "with_comments": true}' + assert_failure + assert_output --partial "separate calls" +} + +@test "issue_view applies jq_filter to the merged document" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true, "jq_filter": ".field_values.Priority"}' + assert_success + assert_output --partial "Low" +} + +@test "issue_view with_field_values reports a failed REST call" { + GH_STUB_REST="gh: Not Found (HTTP 404)" + GH_STUB_REST_EXIT=1 + run tool_issue_view '{"number": 999999, "repo": "shopware/shopware", "with_field_values": true}' + assert_failure + assert_output --partial "Not Found" +} + +@test "issue_view with_field_values honors fallback when the REST call fails" { + GH_STUB_REST="gh: Not Found (HTTP 404)" + GH_STUB_REST_EXIT=1 + run tool_issue_view '{"number": 999999, "repo": "shopware/shopware", "with_field_values": true, "fallback": "no issue"}' + assert_success + assert_output "no issue" +} + +@test "issue_view with_field_values rejects a malformed repository before calling the API" { + run tool_issue_view '{"number": 19952, "owner": "shopware/extra", "repo": "shopware", "with_field_values": true}' + assert_failure + assert_output --partial "owner/repo" + assert_equal "$(grep -c -- "api repos/" "${GH_ARGS_FILE}" 2>/dev/null || printf '0')" "0" +} + +@test "issue_view with_field_values rejects a malformed repository even when fields are requested" { + run tool_issue_view '{"number": 19952, "owner": "shopware/extra", "repo": "shopware", "with_field_values": true, "fields": "title", "fallback": "unavailable"}' + assert_failure + assert_output --partial "owner/repo" +} + +@test "issue_view with_field_values fails on two values naming the same field" { + GH_STUB_REST='{"number":1,"issue_field_values":[{"issue_field_name":"Priority","value":"High"},{"issue_field_name":"Priority","value":"Low"}]}' + run tool_issue_view '{"number": 1, "repo": "shopware/shopware", "with_field_values": true}' + assert_failure + assert_output --partial "more than one value for Priority" +} + +@test "issue_view with_field_values fails on a value with no field name" { + GH_STUB_REST='{"number":1,"issue_field_values":[{"value":"High"}]}' + run tool_issue_view '{"number": 1, "repo": "shopware/shopware", "with_field_values": true}' + assert_failure + assert_output --partial "no field name" +} + +@test "issue_view with_field_values does not answer an undecodable response with fallback" { + GH_STUB_REST='{"number":1,"issue_field_values":[{"value":"High"}]}' + run tool_issue_view '{"number": 1, "repo": "shopware/shopware", "with_field_values": true, "fallback": "unavailable"}' + assert_failure + refute_output --partial "unavailable" +} + +@test "issue_view with_field_values reports the API call's own exit status" { + GH_STUB_REST="gh: Bad credentials (HTTP 401)" + GH_STUB_REST_EXIT=4 + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true}' + assert_equal "${status}" 4 +} + +@test "issue_view without with_field_values makes no REST call" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "fields": "title"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.title')" "Broken thing" + run grep -c -- "api repos/" "${GH_ARGS_FILE}" + assert_output "0" +} diff --git a/plugin-tests/github-mcp/write_tools_issue_schema.bats b/plugin-tests/github-mcp/write_tools_issue_schema.bats new file mode 100644 index 0000000..053fe36 --- /dev/null +++ b/plugin-tests/github-mcp/write_tools_issue_schema.bats @@ -0,0 +1,263 @@ +#!/usr/bin/env bats +# bats file_tags=github-mcp,write-tools +# Tests for the issue_type_set and issue_field_set write tools +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +TYPES_JSON='[{"id":125714,"name":"Bug"},{"id":25328944,"name":"Improvement"}]' +FIELDS_JSON='[{"id":8847,"name":"Priority","data_type":"single_select","options":[{"id":12296,"name":"High"},{"id":12298,"name":"Low"}]},{"id":8848,"name":"Start date","data_type":"date"},{"id":8851,"name":"Points","data_type":"number"},{"id":8852,"name":"Owner","data_type":"text"},{"id":8853,"name":"Teams","data_type":"multi_select","options":[{"id":1,"name":"Core"},{"id":2,"name":"Storefront"}]},{"id":8854,"name":"Flag","data_type":"boolean"}]' + +setup() { + log() { :; } + GH_DEFAULT_REPO="shopware/shopware" + GH_TOOLING_CONFIG_FILE="" + source "${GH_LIB_DIR}/common.sh" + source "${GH_LIB_DIR}/issue_schema_write.sh" + + GH_ARGS_FILE="${BATS_TEST_TMPDIR}/gh_args" + GH_BODY_FILE="${BATS_TEST_TMPDIR}/gh_body" + + # Stub answers the org lookups from fixtures and records the write request. + gh() { + case "$*" in + *orgs/*/issue-types*) printf '%s\n' "${TYPES_JSON}"; return 0 ;; + *orgs/*/issue-fields*) printf '%s\n' "${FIELDS_JSON}"; return 0 ;; + esac + printf '%s\n' "$@" > "${GH_ARGS_FILE}" + cat > "${GH_BODY_FILE}" + [[ -n "${GH_STUB_EXIT:-}" ]] && return "${GH_STUB_EXIT}" + printf '%s\n' "${GH_STUB_OUTPUT}" + return 0 + } + GH_STUB_OUTPUT='{"number":19952,"type":{"name":"Bug"}}' + GH_STUB_EXIT="" +} + +body() { jq -c "$1" "${GH_BODY_FILE}"; } + +# ============================================================================ +# issue_type_set +# ============================================================================ + +@test "issue_type_set PATCHes the issue with the canonical type name" { + run tool_issue_type_set '{"number": 19952, "type": "bug"}' + assert_success + run grep -x -- 'PATCH' "${GH_ARGS_FILE}" + assert_success + run grep -x -- 'repos/shopware/shopware/issues/19952' "${GH_ARGS_FILE}" + assert_success + assert_equal "$(body '.type')" '"Bug"' +} + +@test "issue_type_set sends a null type to clear it" { + GH_STUB_OUTPUT='{"number":19952,"type":null}' + run tool_issue_type_set '{"number": 19952, "type": null}' + assert_success + assert_equal "$(body '.type')" 'null' + assert_equal "$(printf '%s' "${output}" | jq -r '.type')" 'null' +} + +@test "issue_type_set rejects an unknown type before calling the API" { + run tool_issue_type_set '{"number": 19952, "type": "Bogus"}' + assert_failure + assert_output --partial "Available types: Bug, Improvement" + [ ! -f "${GH_ARGS_FILE}" ] +} + +@test "issue_type_set requires the type key" { + run tool_issue_type_set '{"number": 19952}' + assert_failure + assert_output --partial "type is required" +} + +@test "issue_type_set requires a number" { + run tool_issue_type_set '{"type": "Bug"}' + assert_failure + assert_output --partial "number is required" +} + +@test "issue_type_set returns the fallback when the API call fails" { + GH_STUB_EXIT=1 + run tool_issue_type_set '{"number": 19952, "type": "Bug", "fallback": "unchanged"}' + assert_success + assert_output "unchanged" +} + +# ============================================================================ +# issue_field_set +# ============================================================================ + +@test "issue_field_set PUTs the whole set with resolved field ids" { + GH_STUB_OUTPUT='[{"issue_field_name":"Priority","single_select_option":{"name":"High"}}]' + run tool_issue_field_set '{"number": 19952, "values": {"Priority": "high"}}' + assert_success + run grep -x -- 'PUT' "${GH_ARGS_FILE}" + assert_success + run grep -x -- 'repos/shopware/shopware/issues/19952/issue-field-values' "${GH_ARGS_FILE}" + assert_success + assert_equal "$(body '.issue_field_values')" '[{"field_id":8847,"value":"High"}]' +} + +@test "issue_field_set sends an empty array when values is empty" { + GH_STUB_OUTPUT='[]' + run tool_issue_field_set '{"number": 19952, "values": {}}' + assert_success + assert_equal "$(body '.issue_field_values')" '[]' +} + +@test "issue_field_set passes date, number, and text values through unchanged" { + GH_STUB_OUTPUT='[]' + run tool_issue_field_set '{"number": 19952, "values": {"Start date": "2026-09-30", "Points": 5, "Owner": "core"}}' + assert_success + assert_equal "$(body '[.issue_field_values[].value]')" '["2026-09-30",5,"core"]' +} + +@test "issue_field_set rejects an unknown field before calling the API" { + run tool_issue_field_set '{"number": 19952, "values": {"Prioriti": "High"}}' + assert_failure + assert_output --partial "Available fields: Priority, Start date, Points, Owner" + [ ! -f "${GH_ARGS_FILE}" ] +} + +@test "issue_field_set rejects an unknown single-select option" { + run tool_issue_field_set '{"number": 19952, "values": {"Priority": "Urgent"}}' + assert_failure + assert_output --partial "Available options: High, Low" +} + +@test "issue_field_set rejects a malformed date" { + run tool_issue_field_set '{"number": 19952, "values": {"Start date": "30.09.2026"}}' + assert_failure + assert_output --partial "takes a date as YYYY-MM-DD" +} + +@test "issue_field_set rejects a non-numeric value for a number field" { + run tool_issue_field_set '{"number": 19952, "values": {"Points": "five"}}' + assert_failure + assert_output --partial "takes a number" +} + +@test "issue_field_set reports every rejected entry at once" { + run tool_issue_field_set '{"number": 19952, "values": {"Prioriti": "High", "Points": "five"}}' + assert_failure + assert_output --partial "Prioriti" + assert_output --partial "takes a number" +} + +@test "issue_field_set requires the values key" { + run tool_issue_field_set '{"number": 19952}' + assert_failure + assert_output --partial "values is required" +} + +@test "issue_field_set requires a number" { + run tool_issue_field_set '{"values": {}}' + assert_failure + assert_output --partial "number is required" +} + +@test "issue_field_set returns the fallback when the API call fails" { + GH_STUB_EXIT=1 + run tool_issue_field_set '{"number": 19952, "values": {}, "fallback": "unchanged"}' + assert_success + assert_output "unchanged" +} + +@test "issue_field_set shapes the response like the values it takes" { + GH_STUB_OUTPUT='[{"issue_field_name":"Priority","value":12296,"single_select_option":{"id":12296,"name":"High"}},{"issue_field_name":"Start date","value":"2026-09-30"},{"issue_field_name":"Areas","multi_select_options":[{"id":13001,"name":"Storefront"}]}]' + run tool_issue_field_set '{"number": 19952, "values": {"Priority": "High"}}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -c '.')" '{"Priority":"High","Start date":"2026-09-30","Areas":["Storefront"]}' +} + +@test "issue_field_set resolves a multi-select array to canonical option names" { + GH_STUB_OUTPUT='[]' + run tool_issue_field_set '{"number": 19952, "values": {"Teams": ["core", "Storefront"]}}' + assert_success + assert_equal "$(body '.issue_field_values')" '[{"field_id":8853,"value":["Core","Storefront"]}]' +} + +@test "issue_field_set rejects a non-array value for a multi-select field" { + run tool_issue_field_set '{"number": 19952, "values": {"Teams": "Core"}}' + assert_failure + assert_output --partial "takes an array of option names" +} + +@test "issue_field_set rejects an unknown option inside a multi-select array" { + run tool_issue_field_set '{"number": 19952, "values": {"Teams": ["Core", "Nope"]}}' + assert_failure + assert_output --partial "Available options: Core, Storefront" +} + +@test "issue_field_set with suppress_errors returns no error text" { + GH_STUB_EXIT=1 + run tool_issue_field_set '{"number": 19952, "values": {}, "suppress_errors": true}' + assert_failure + assert_output "" +} + +@test "issue_field_set rejects a null or array values argument instead of clearing everything" { + run tool_issue_field_set '{"number": 19952, "values": null}' + assert_failure + assert_output --partial "must be an object" + [ ! -f "${GH_ARGS_FILE}" ] + + run tool_issue_field_set '{"number": 19952, "values": []}' + assert_failure + assert_output --partial "must be an object" + [ ! -f "${GH_ARGS_FILE}" ] +} + +@test "issue_field_set rejects non-string elements in a multi-select array" { + run tool_issue_field_set '{"number": 19952, "values": {"Teams": [1, 2]}}' + assert_failure + assert_output --partial "array of option names as strings" +} + +@test "issue_field_set rejects a malformed repo" { + run tool_issue_field_set '{"number": 19952, "values": {}, "repo": "widgets"}' + assert_failure + assert_output --partial "owner/repo" +} + +@test "issue_type_set rejects a malformed repo" { + run tool_issue_type_set '{"number": 19952, "type": "Bug", "repo": "widgets"}' + assert_failure + assert_output --partial "owner/repo" +} + +@test "fallback does not mask an unknown field name" { + run tool_issue_field_set '{"number": 19952, "values": {"Ghost": "x"}, "fallback": "ok"}' + assert_failure + assert_output --partial "not found" +} + +@test "fallback does not mask an unknown issue type" { + run tool_issue_type_set '{"number": 19952, "type": "Bogus", "fallback": "ok"}' + assert_failure + assert_output --partial "Available types" +} + +@test "issue_field_set rejects a date with trailing whitespace or an impossible month" { + run tool_issue_field_set '{"number": 19952, "values": {"Start date": "2026-09-30\n"}}' + assert_failure + assert_output --partial "YYYY-MM-DD" + + run tool_issue_field_set '{"number": 19952, "values": {"Start date": "2026-99-99"}}' + assert_failure + assert_output --partial "YYYY-MM-DD" +} + +@test "issue_field_set rejects a field whose data type it does not support" { + run tool_issue_field_set '{"number": 19952, "values": {"Flag": "yes"}}' + assert_failure + assert_output --partial "unsupported data type boolean" +} + +@test "issue_field_set rejects two keys that name the same field" { + run tool_issue_field_set '{"number": 19952, "values": {"Priority": "High", "priority": "Low"}}' + assert_failure + assert_output --partial "name the same issue field" + [ ! -f "${GH_ARGS_FILE}" ] +} diff --git a/plugins/github-mcp/.claude-plugin/plugin.json b/plugins/github-mcp/.claude-plugin/plugin.json index 5d62403..c975b73 100644 --- a/plugins/github-mcp/.claude-plugin/plugin.json +++ b/plugins/github-mcp/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "github-mcp", "version": "3.5.0", - "description": "GitHub CLI MCP servers wrapping gh for pull requests, issues, CI runs, jobs, commits, search, labels, projects, and reviews. Read server (always active) with 30 tools and write server (opt-in) with 23 tools. Includes SessionStart hook that injects MCP tool directives and PreToolUse hooks that enforce MCP tool usage. Configuration-optional: works without config when gh is authenticated.", + "description": "GitHub CLI MCP servers wrapping gh for pull requests, issues, CI runs, jobs, commits, search, labels, projects, and reviews. Read server (always active) with 31 tools and write server (opt-in) with 25 tools. Includes SessionStart hook that injects MCP tool directives and PreToolUse hooks that enforce MCP tool usage. Configuration-optional: works without config when gh is authenticated.", "author": { "name": "Shopware Labs" }, diff --git a/plugins/github-mcp/AGENTS.md b/plugins/github-mcp/AGENTS.md index 429aa69..a4fe6ab 100644 --- a/plugins/github-mcp/AGENTS.md +++ b/plugins/github-mcp/AGENTS.md @@ -5,7 +5,7 @@ ``` plugins/github-mcp/ ├── README.md # User documentation (usage, configuration, troubleshooting) -├── REFERENCE.md # Full tool parameter docs and examples (30 read + 23 write tools) +├── REFERENCE.md # Full tool parameter docs and examples (31 read + 25 write tools) ├── AGENTS.md # LLM navigation guide (this file) ├── CHANGELOG.md # Version history │ @@ -32,15 +32,17 @@ plugins/github-mcp/ ├── server-write.sh # Write server entry point - gated by enable_write_server config ├── config-read.json # Read server metadata (name="gh-tooling") ├── config-write.json # Write server metadata (name="gh-tooling-write") - ├── tools-read.json # 30 read tools (PR, issue, CI, commit, search, repo, release, label, project, api_read) - ├── tools-write.json # 23 write tools (PR lifecycle, reviews, issues, labels, assignees, sub-issues, projects, api) + ├── tools-read.json # 31 read tools (PR, issue, CI, commit, search, repo, release, label, project, api_read) + ├── tools-write.json # 25 write tools (PR lifecycle, reviews, issues, issue types/fields, labels, assignees, sub-issues, projects, api) ├── mcp-gh-tooling.schema.json # JSON Schema for .mcp-gh-tooling.json └── lib/ ├── common.sh # _gh_validate_number/repo/sha(), _gh_resolve_repo(), _gh_validate_jq_filter(), _gh_post_process(), _gh_parse_github_url(), _gh_validate_path(), _gh_download_file(), _gh_resolve_owner_repo() ├── pr.sh # tool_pr_view/diff/list/checks/comments/reviews/files/commits() ├── pr_write.sh # tool_pr_create/edit/ready/merge/close/reopen() ├── issue.sh # tool_issue_view(), tool_issue_list() + ├── 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() ├── 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() @@ -60,8 +62,8 @@ plugins/github-mcp/ This plugin provides: - **Two MCP Servers** via `.mcp.json` in Claude Code and inline `mcpServers` in `.codex-plugin/plugin.json` in Codex: - - `gh-tooling` (read) - 30 read-only GitHub tools (PRs, issues, CI, commits, search, repo, releases, labels, projects, read-only API) - - `gh-tooling-write` (write) - 23 write tools (PR lifecycle, reviews, issues, labels, assignees, sub-issues, projects, full API). Gated by `enable_write_server` config flag. + - `gh-tooling` (read) - 31 read-only GitHub tools (PRs, issues, CI, commits, search, repo, releases, labels, projects, read-only API) + - `gh-tooling-write` (write) - 25 write tools (PR lifecycle, reviews, issues, issue types/fields, labels, assignees, sub-issues, projects, full API). Gated by `enable_write_server` config flag. - **SessionStart Hook** via the shared `hooks/hooks.json`: - Assembles MCP tool directives dynamically from template with conditional write and label sections - Prompt template maintained in `hooks/prompts/mcp-tool-directives.md` @@ -185,6 +187,8 @@ BATS tests for hook scripts and MCP tool functions are in `plugin-tests/github-m | `check_api_tools.bats` | Dedicated API-tool enforcement for Claude Code and Codex tool namespaces | | `session_start.bats` | Shared SessionStart context and host-specific config discovery | | `write_server_gating.bats` | Write-server gating and active-host config priority | +| `read_tools_issue_schema.bats` | `issue_schema` org resolution, name filters, and merge output | +| `write_tools_issue_schema.bats` | `issue_type_set` and `issue_field_set` name resolution and value checks | | `mcp_tool_gh.bats` | MCP tool shared parameters (_gh_validate_jq_filter, _gh_post_process, suppress_errors, fallback) | | `tool_schemas.bats` | Shipped tool schemas against the vendored validator: identifier unions, required fields, defaults, and validation round-trips | diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index 1fad637..772312a 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `issue_schema` read tool. Returns an organization's issue types and issue fields in one call, with the options of every single-select field, so an agent can look up the exact names before setting an issue's type or field values. The organization is resolved from `org`, `owner`, a repository parameter, the configured default repo, or the current clone. `type` and `field` each match one name case-insensitively and narrow only their own list; a name that matches nothing is an error rather than an empty list. Backed by `orgs/{org}/issue-types` and `orgs/{org}/issue-fields`. +- Dedicated-tool enforcement for `orgs/{org}/issue-types` and `orgs/{org}/issue-fields` in `check-api-tools.sh`, active when `block_api_tool_read` is enabled. +- `issue_type_set` write tool. Sets an issue's type by name, or clears it with `null`. The name is matched case-insensitively against the organization's types and sent in its canonical spelling; an unknown name fails before the write, listing the available types. +- `issue_field_set` write tool. Replaces an issue's field values with the given `values` object, keyed by field name — a field left out is cleared, and `{}` clears them all. Field and option names resolve to IDs against the organization's schema, and each value is checked against its field's data type (option name, array of option names for multi-select, `YYYY-MM-DD`, number, string) before the request, because GitHub reports an unknown field ID as a wrong option name. The response repeats the resulting values in the same shape the parameter takes — keyed by field name, with option names rather than option IDs — so it can be edited and sent back as the next call's `values`. +- `with_field_values` on `issue_view`. Returns the issue's type name and its field values as `{"type": ..., "field_values": {...}}`, keyed by field name and reporting select options by name. `gh issue view` exposes neither the type by default nor the field values at all, and the REST issue reports a single-select as an option ID while writes take the option name, so this is the read that pairs with `issue_field_set`: read the object, change an entry, pass the whole object back. Output is always JSON; `fields` adds gh's own fields to the same document, and `with_comments` is refused alongside it because that output is text. +- `pattern` constraints on the repository and organization parameters of the three new tools: `issue_type_set.repo` and `issue_field_set.repo` carry the `owner/repo` shape that `_gh_validate_repo` enforces, and `issue_schema.org` and `issue_schema.owner` carry the login shape that `_gh_validate_org` enforces. The vendored SDK enforces `pattern` on string values, so a malformed repository or organization is now rejected before dispatch; the tool functions validate these values as well, which is what still holds for a call that reaches them another way. `issue_schema.repo` is deliberately left unconstrained because it also accepts a bare repository name alongside `owner`. +- Dedicated-tool enforcement for `issues/{n}/issue-field-values` and `PATCH repos/{owner}/{repo}/issues/{n}` in `check-api-tools.sh`. A write of an issue's field values routes to `issue_field_set` under `block_api_tool_write`; a `GET` of the same path routes to `issue_view` with `with_field_values`, under `block_api_tool_read`. + ### Changed - `shared/mcpserver_core.sh` is now vendored from [shopwareLabs/bash-mcp-sdk](https://github.com/shopwareLabs/bash-mcp-sdk) `v2.0.0` instead of being maintained in this repository. The file is byte-identical to `lib/mcpserver_core.sh` at that tag; `.mcp-sdk.lock` records the release and `renovate.json` opens a PR when a new one is published. Protocol changes now go to the SDK repository and arrive here as a lock bump — a local edit is overwritten by the next update. - Tool-call argument validation now enforces a declared `type`, a declared `pattern` on string values, `items.type` / `items.enum` on every element of an array, and `enum`. The `required` and `additionalProperties` checks were already applied. Diagnostics report the most fundamental defect first, in the order missing, unknown, type, pattern, items, enum. **Breaking for callers:** an argument of the wrong single type that was previously accepted and passed through to `gh` now returns an `isError` result naming the parameter, its expected type and the value received. This affects the integer-typed paging and output parameters — `limit`, `max_lines`, `tail_lines`, `grep_context_before`, `grep_context_after`, `line_start`, `line_end` — where a quoted number such as `"20"` is now refused. A `type` declared as a list of alternatives is enforced the same way: identifier parameters declare `["integer", "string"]` and accept `339` and `"339"` alike, while a value of neither type is refused naming both — `number expected integer or string, got boolean`. diff --git a/plugins/github-mcp/README.md b/plugins/github-mcp/README.md index a0c1baf..a966b81 100644 --- a/plugins/github-mcp/README.md +++ b/plugins/github-mcp/README.md @@ -7,7 +7,7 @@ GitHub CLI tools via MCP (Model Context Protocol). Wraps the `gh` CLI for pull r ### Read Server (gh-tooling) - **PR inspection** via `pr_view`, `pr_diff`, `pr_list`, `pr_checks` - **PR review data** via `pr_comments`, `pr_reviews`, `pr_files`, `pr_commits` -- **Issue operations** via `issue_view`, `issue_list` +- **Issue operations** via `issue_view`, `issue_list`, `issue_schema` - **GitHub Actions CI** via `run_view`, `run_list`, `run_logs`, `workflow_jobs` - **Job-level CI debugging** via `job_view`, `job_logs`, `job_annotations` - **Commit PR lookup** via `commit_pulls` @@ -151,15 +151,15 @@ Configuration is loaded in the following priority order: ## Tools Reference -30 read tools + 23 write tools organized by category. See [REFERENCE.md](./REFERENCE.md) for full parameter docs and examples. +31 read tools + 25 write tools organized by category. See [REFERENCE.md](./REFERENCE.md) for full parameter docs and examples. -### Read Server (gh-tooling) -- 30 tools +### Read Server (gh-tooling) -- 31 tools | Category | Tools | |----------------|---------------------------------------------------------------------------------| | PR inspection | `pr_view`, `pr_diff`, `pr_list`, `pr_checks` | | PR review data | `pr_comments`, `pr_reviews`, `pr_files`, `pr_commits` | -| Issues | `issue_view`, `issue_list` | +| Issues | `issue_view`, `issue_list`, `issue_schema` | | CI runs | `run_view`, `run_list`, `run_logs`, `workflow_jobs` | | CI jobs | `job_view`, `job_logs`, `job_annotations` | | Commits | `commit_pulls` | @@ -170,13 +170,14 @@ Configuration is loaded in the following priority order: | Projects | `project_list`, `project_view` | | Raw API | `api_read` (GET only) | -### Write Server (gh-tooling-write) -- 23 tools +### Write Server (gh-tooling-write) -- 25 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` | | Issues | `issue_create`, `issue_edit`, `issue_close`, `issue_reopen`, `issue_comment` | +| Issue schema | `issue_type_set`, `issue_field_set` | | Labels | `label_add`, `label_remove` | | Assignees | `assignee_add`, `assignee_remove` | | Sub-issues | `sub_issue_add`, `sub_issue_remove` | diff --git a/plugins/github-mcp/REFERENCE.md b/plugins/github-mcp/REFERENCE.md index 435c70c..f039504 100644 --- a/plugins/github-mcp/REFERENCE.md +++ b/plugins/github-mcp/REFERENCE.md @@ -2,7 +2,7 @@ ## Read Server (gh-tooling) -30 tools available via the `gh-tooling` MCP server. Requires `gh` CLI installed and authenticated. +31 tools available via the `gh-tooling` MCP server. Requires `gh` CLI installed and authenticated. ### Shared Tool Parameters @@ -142,8 +142,20 @@ View a GitHub issue. Use gh-tooling issue_view with number 8498 Use gh-tooling issue_view with number 8498 and with_comments true Use gh-tooling issue_view with number 8498 and fields "title,body,state,labels,comments" +Use gh-tooling issue_view with number 8498 and with_field_values true ``` +`with_field_values` returns the issue's type and its field values keyed by field +name, which `gh issue view` exposes for neither: + +```json +{"type": "Bug", "field_values": {"Priority": "Low", "Effort": "Low"}} +``` + +The `field_values` object is the shape `issue_field_set` takes, so it can be read, +edited, and passed straight back. Output is always JSON; combine it with `fields` +to add gh's own fields to the same document, and request comments separately. + ### `issue_list` List issues with filters. @@ -152,6 +164,25 @@ List issues with filters. Use gh-tooling issue_list with search "TODO label:component/core" and limit 20 ``` +### `issue_schema` + +List an organization's issue types and issue fields, including each single-select field's options. +The organization comes from `org`, `owner`, a repository parameter, the configured default repo, or the +current clone's remote. + +Types and fields are independent. GitHub lets an organization pin fields to a type, but that pinning +only drives the web UI: any organization field can be set on an issue of any type, so this tool +reports the two lists side by side instead of nesting fields under types. + +`type` and `field` match one name exactly, case-insensitively, and each narrows only its own list. A +name that matches nothing is an error rather than an empty list. + +``` +Use gh-tooling issue_schema with repo "shopware/shopware" +Use gh-tooling issue_schema with org "shopware" and type "Bug" +Use gh-tooling issue_schema with field "Priority" and jq_filter "[.fields[0].options[].name]" +``` + ### `run_view` View the status of a GitHub Actions workflow run. @@ -514,7 +545,7 @@ Use gh-tooling api_read with endpoint "search/issues" and jq_filter ".items[] | ## Write Server (gh-tooling-write) -23 tools available via the `gh-tooling-write` MCP server. Requires `enable_write_server: true` in `.mcp-gh-tooling.json`. +25 tools available via the `gh-tooling-write` MCP server. Requires `enable_write_server: true` in `.mcp-gh-tooling.json`. ### Shared Tool Parameters @@ -754,6 +785,48 @@ Use gh-tooling-write issue_comment with number 8498 and body "This has been fixe - `body` (string, required): Comment text to post. - `repo` (string, optional): Repository in `owner/repo` format. +### Issue Type and Field Write Tools + +Both tools take names, not IDs, and resolve them against the organization's schema before calling the +API, so an unknown name fails with the valid ones listed. Use `issue_schema` to see what is available. +Issue types and issue fields apply to issues only, not pull requests. + +#### `issue_type_set` + +Set or clear an issue's type. The name replaces whatever the issue carried before, and `null` clears +it, so repeating the same call leaves the issue in the same state. + +``` +Use gh-tooling-write issue_type_set with number 19952 and type "Bug" +Use gh-tooling-write issue_type_set with number 19952 and type null +``` + +**Parameters:** +- `number` (integer, required): Issue number. +- `type` (string or null, required): Issue type name, matched case-insensitively, or `null` to clear. +- `repo` (string, optional): Repository in `owner/repo` format. + +#### `issue_field_set` + +Replace an issue's field values. The `values` object becomes the issue's **complete** set: a field +left out is cleared, and `{}` clears them all. To change one field without dropping the others, read +the current values with `issue_view` and `with_field_values true`, edit the entry you want, and pass +the whole object back — its `field_values` object and this `values` object are the same shape, and so +is this tool's own response. + +Values are typed by the field: a single-select takes an option name, a multi-select takes an array of +option names, a date takes `YYYY-MM-DD`, a number takes a number, and a text field takes a string. + +``` +Use gh-tooling-write issue_field_set with number 19952 and values {"Priority": "High", "Effort": "Low"} +Use gh-tooling-write issue_field_set with number 19952 and values {} +``` + +**Parameters:** +- `number` (integer, required): Issue number. +- `values` (object, required): Complete set of field values keyed by field name. `{}` clears every value. +- `repo` (string, optional): Repository in `owner/repo` format. + ### Label Write Tools #### `label_add` diff --git a/plugins/github-mcp/hooks/prompts/mcp-tool-directives.md b/plugins/github-mcp/hooks/prompts/mcp-tool-directives.md index 8c06d62..1c43135 100644 --- a/plugins/github-mcp/hooks/prompts/mcp-tool-directives.md +++ b/plugins/github-mcp/hooks/prompts/mcp-tool-directives.md @@ -8,7 +8,7 @@ Repository selection (PR / issue / search / commit / repo tools): pass `repo` (o ## Read (gh-tooling) PRs: pr_view, pr_diff, pr_list, pr_checks, pr_comments, pr_reviews, pr_files, pr_commits -Issues: issue_view, issue_list +Issues: issue_view, issue_list, issue_schema CI: run_view, run_list, run_logs, workflow_jobs, job_view, job_logs, job_annotations Commits: commit_pulls Search: search, search_code, search_repos, search_commits, search_discussions diff --git a/plugins/github-mcp/hooks/prompts/write-operations-enabled.md b/plugins/github-mcp/hooks/prompts/write-operations-enabled.md index a9bb80b..b96217b 100644 --- a/plugins/github-mcp/hooks/prompts/write-operations-enabled.md +++ b/plugins/github-mcp/hooks/prompts/write-operations-enabled.md @@ -2,6 +2,7 @@ PRs: pr_create, pr_edit, pr_ready, pr_merge, pr_close, pr_reopen Reviews: pr_review_submit, pr_comment, pr_review_reply 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 Assignees: assignee_add, assignee_remove Sub-issues: sub_issue_add, sub_issue_remove diff --git a/plugins/github-mcp/hooks/scripts/check-api-tools.sh b/plugins/github-mcp/hooks/scripts/check-api-tools.sh index 7d92d6f..ca3830d 100755 --- a/plugins/github-mcp/hooks/scripts/check-api-tools.sh +++ b/plugins/github-mcp/hooks/scripts/check-api-tools.sh @@ -107,6 +107,16 @@ if echo "$ENDPOINT" | grep -qE 'labels(\?|$)'; then block_tool "label_list" "Use label_list with optional repo and filter parameters." fi +# Issue field values on one issue +if echo "$ENDPOINT" | grep -qE 'issues/[0-9]+/issue-field-values'; then + block_tool "issue_view" "Use issue_view with number and with_field_values true. It returns the issue's type and field values keyed by field name." +fi + +# Organization issue types and issue fields +if echo "$ENDPOINT" | grep -qE 'orgs/[^/]+/issue-(types|fields)'; then + block_tool "issue_schema" "Use issue_schema with optional org and type/field name filters. It returns both collections in one call." +fi + fi # end GET-only read endpoint mapping # ============================================================================ @@ -136,6 +146,16 @@ if [[ "$IS_WRITE" == "true" ]]; then block_tool "issue_comment" "Use issue_comment with number and body." fi + # Issue field values (POST/PUT/DELETE; a GET is handled by the read section) + if [[ "$METHOD" != "GET" ]] && echo "$ENDPOINT" | grep -qE 'issues/[0-9]+/issue-field-values'; then + block_tool "issue_field_set" "Use issue_field_set with number and a values object keyed by field name. It replaces the issue's whole set of field values." + fi + + # Issue metadata, including the issue type (PATCH) + if [[ "$METHOD" == "PATCH" ]] && echo "$ENDPOINT" | grep -qE 'repos/[^/]+/[^/]+/issues/[0-9]+(\?|$)'; 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 + # 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 dcf6d3d..1b6bbb4 100644 --- a/plugins/github-mcp/hooks/scripts/check-gh-tools.sh +++ b/plugins/github-mcp/hooks/scripts/check-gh-tools.sh @@ -159,7 +159,7 @@ fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*gh\s+issue\s+edit(\s|$)'; then block_tool "mcp__gh-tooling-write__issue_edit" \ - "Use issue_edit with number, title, body, labels, and assignees parameters." + "Use issue_edit with number, title, body, labels, and assignees parameters, or issue_type_set to change the issue type." fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*gh\s+issue\s+close(\s|$)'; then diff --git a/plugins/github-mcp/mcp-server-gh/lib/common.sh b/plugins/github-mcp/mcp-server-gh/lib/common.sh index 7768c83..2c30d60 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/common.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/common.sh @@ -69,6 +69,77 @@ _gh_require_repo_or_git() { return 1 } +####################################### +# Reject an organization login that is not one path segment of GitHub's login +# charset, so it cannot steer the request to a different API path. +# Arguments: +# $1 candidate login, $2 tool name for the error message. +# Outputs: +# An error message on stdout when the login is malformed. +# Returns: +# 0 when the login is usable, 1 otherwise. +####################################### +_gh_validate_org() { + local org="$1" tool="$2" + if [[ ! "${org}" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]]; then + printf '%s\n' "Error: invalid organization '${org}' for ${tool}. Expected a GitHub organization login." + return 1 + fi +} + +####################################### +# Resolve the organization owning org-level resources (issue types, issue fields). +# Priority: org > owner > repo-shaped args > GH_DEFAULT_REPO > git remote. +# Globals: +# GH_DEFAULT_REPO, _GH_OWNER +# Arguments: +# $1 JSON args string, $2 tool name for the error message. +# Outputs: +# Organization login on stdout, or an error message on stdout. +# Returns: +# 0 when an organization was resolved, 1 otherwise. +####################################### +_gh_resolve_org() { + local args="$1" tool="$2" + + local org owner + org=$(printf '%s\n' "${args}" | jq -r '.org // empty') + owner=$(printf '%s\n' "${args}" | jq -r '.owner // empty') + + if [[ -n "${org}" ]]; then + _gh_validate_org "${org}" "${tool}" || return 1 + printf '%s\n' "${org}" + return 0 + fi + if [[ -n "${owner}" ]]; then + _gh_validate_org "${owner}" "${tool}" || return 1 + printf '%s\n' "${owner}" + return 0 + fi + + # Not run in a command substitution: the resolver reports through globals, + # which a subshell would discard. Its own error text goes to our stdout. + if ! _gh_resolve_owner_repo_optional "${args}"; then + return 1 + fi + if [[ -n "${_GH_OWNER}" ]]; then + _gh_validate_org "${_GH_OWNER}" "${tool}" || return 1 + printf '%s\n' "${_GH_OWNER}" + return 0 + fi + + local name_with_owner + name_with_owner=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) || true + if [[ -n "${name_with_owner}" ]]; then + _gh_validate_org "${name_with_owner%%/*}" "${tool}" || return 1 + printf '%s\n' "${name_with_owner%%/*}" + return 0 + fi + + printf '%s\n' "Error: org is required for ${tool}. Pass 'org', 'owner', or a repository ('repository', 'repo', or 'owner'+'repo'), or set 'repo' in .mcp-gh-tooling.json" + return 1 +} + # Read a value from the gh-tooling config file # Args: $1 = jq path (e.g. '.repo'), $2 = default value _gh_config_value() { diff --git a/plugins/github-mcp/mcp-server-gh/lib/issue.sh b/plugins/github-mcp/mcp-server-gh/lib/issue.sh index 5b9f110..cf9079e 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/issue.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/issue.sh @@ -2,15 +2,17 @@ # Issue tools for gh-tooling MCP server # Tools: issue_view, issue_list -# View a GitHub issue with optional comments. +# View a GitHub issue with optional comments, type, and field values. # Maps to: gh issue view [--repo owner/repo] [--json ] [--comments] +# and gh api repos//issues/ for the type and field values tool_issue_view() { local args="$1" - local number fields with_comments jq_filter suppress_errors fallback max_lines + local number fields with_comments with_field_values jq_filter suppress_errors fallback max_lines number=$(echo "${args}" | jq -r '.number // empty') fields=$(echo "${args}" | jq -r '.fields // empty') with_comments=$(echo "${args}" | jq -r '.with_comments // false') + with_field_values=$(echo "${args}" | jq -r '.with_field_values // false') jq_filter=$(echo "${args}" | jq -r '.jq_filter // empty') suppress_errors=$(echo "${args}" | jq -r '.suppress_errors // false') fallback=$(echo "${args}" | jq -r '.fallback // empty') @@ -20,6 +22,10 @@ tool_issue_view() { echo "Error: number is required for issue_view" return 1 fi + if [[ "${with_field_values}" == "true" && "${with_comments}" == "true" ]]; then + echo "Error: with_field_values returns JSON and with_comments returns text; request them in separate calls" + return 1 + fi _gh_validate_number "${number}" "number" || return 1 _gh_validate_jq_filter "${jq_filter}" || return 1 @@ -28,18 +34,86 @@ tool_issue_view() { [[ -n "${_GH_OWNER}" ]] && effective_repo="${_GH_OWNER}/${_GH_REPO}" _gh_require_repo_or_git "${effective_repo}" || return 1 - local -a cmd=("gh" "issue" "view" "${number}") + # The REST path is built by concatenation below, unlike --repo which gh + # parses itself, so a resolved repo that is not owner/repo is rejected before + # any call: the split form only checks that `repo` has no slash, and letting + # it reach gh would turn an input error into a fallback success. + if [[ "${with_field_values}" == "true" ]]; then + _gh_validate_repo "${effective_repo}" || return 1 + fi - if [[ -n "${effective_repo}" ]]; then - cmd+=("--repo" "${effective_repo}") + local doc="{}" + + # gh issue view has nothing to contribute when the caller asked only for the + # type and field values, which come from the REST issue instead. + if [[ -n "${fields}" || "${with_field_values}" != "true" ]]; then + local -a cmd=("gh" "issue" "view" "${number}") + + if [[ -n "${effective_repo}" ]]; then + cmd+=("--repo" "${effective_repo}") + fi + + if [[ -n "${fields}" ]]; then + cmd+=("--json" "${fields}") + elif [[ "${with_comments}" == "true" ]]; then + cmd+=("--comments") + fi + + log "INFO" "issue_view: ${cmd[*]}" + local __raw __exit=0 + if [[ "${suppress_errors}" == "true" ]]; then + __raw=$("${cmd[@]}" 2>/dev/null) || __exit=$? + else + __raw=$("${cmd[@]}" 2>&1) || __exit=$? + fi + if [[ ${__exit} -ne 0 ]]; then + [[ -n "${fallback}" ]] && { echo "${fallback}"; return 0; } + echo "${__raw}"; return ${__exit} + fi + + if [[ "${with_field_values}" != "true" ]]; then + _gh_post_process "${__raw}" "${jq_filter}" "" 0 0 false false "${max_lines}" "" || return $? + return 0 + fi + doc="${__raw}" fi - if [[ -n "${fields}" ]]; then - cmd+=("--json" "${fields}") - elif [[ "${with_comments}" == "true" ]]; then - cmd+=("--comments") + local rest __fv_exit=0 + rest=$(_gh_issue_rest_issue "${effective_repo}" "${number}" "${suppress_errors}") || __fv_exit=$? + if [[ ${__fv_exit} -ne 0 ]]; then + [[ -n "${fallback}" ]] && { echo "${fallback}"; return 0; } + echo "${rest}"; return ${__fv_exit} fi + # A response this tool cannot decode is its own failure, not the failed API + # call fallback stands in for, so it is reported either way. + local extra + extra=$(_gh_issue_field_values "${rest}") || { + echo "${extra}" + return 1 + } + + # The requested fields go in on stdin: an issue body or comment thread can be + # larger than a command line holds. + local merged + merged=$(printf '%s' "${doc}" | jq --argjson extra "${extra}" '. + $extra') || { + echo "Error: could not merge the issue's field values into the requested fields" + return 1 + } + + _gh_post_process "${merged}" "${jq_filter}" "" 0 0 false false "${max_lines}" "" || return $? +} + +# Fetch an issue's REST representation, which carries the type and field values +# that gh issue view does not expose. +_gh_issue_rest_issue() { + local effective_repo="$1" number="$2" suppress_errors="$3" + + local path="repos/{owner}/{repo}/issues/${number}" + [[ -n "${effective_repo}" ]] && path="repos/${effective_repo}/issues/${number}" + + local -a cmd=("gh" "api" "${path}") + log "INFO" "issue_view: ${cmd[*]}" local __raw __exit=0 if [[ "${suppress_errors}" == "true" ]]; then @@ -48,10 +122,49 @@ tool_issue_view() { __raw=$("${cmd[@]}" 2>&1) || __exit=$? fi if [[ ${__exit} -ne 0 ]]; then - [[ -n "${fallback}" ]] && { echo "${fallback}"; return 0; } - echo "${__raw}"; return ${__exit} + echo "${__raw}" + return ${__exit} + fi + echo "${__raw}" +} + +# Reduce a REST issue to the type name and the field values in the shape +# issue_field_set takes. The REST issue reports a single-select value as an +# option id while writes take the option name, so the names are read off the +# response's option objects rather than the raw value. Two values naming the +# same field are an error rather than a silent collapse: keying by name is what +# makes the result writable, and from_entries would keep only the last. +_gh_issue_field_values() { + local rest="$1" + + local decoded + decoded=$(printf '%s' "${rest}" | jq ' + [(.issue_field_values // [])[] | { + key: .issue_field_name, + value: (if .multi_select_options != null then [.multi_select_options[].name] + elif .single_select_option != null then .single_select_option.name + else .value end) + }] as $entries + | [$entries[].key] as $names + | if ($names | any(. == null)) then + {_error: "the response holds a field value with no field name"} + elif ($names | length) != ($names | unique | length) then + {_error: "the response holds more than one value for \($names | group_by(.) | map(select(length > 1)) | map(.[0]) | join(", "))"} + else + {type: (.type.name // null), field_values: ($entries | from_entries)} + end') || { + echo "Error: could not read the issue's type and field values" + return 1 + } + + local decode_error + decode_error=$(printf '%s' "${decoded}" | jq -r '._error // empty') + if [[ -n "${decode_error}" ]]; then + echo "Error: could not read the issue's field values: ${decode_error}" + return 1 fi - _gh_post_process "${__raw}" "${jq_filter}" "" 0 0 false false "${max_lines}" "" || return $? + + printf '%s\n' "${decoded}" } # List issues with optional filters. diff --git a/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh b/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh new file mode 100644 index 0000000..5734501 --- /dev/null +++ b/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Issue type and issue field schema tools for gh-tooling MCP server +# Read: issue_schema + +####################################### +# List an organization's issue types and issue fields as one JSON document. +# Types and fields are independent: GitHub pins fields to types for the web UI +# only, and any org field can be set on an issue of any type, so this tool +# reports the two lists side by side rather than nesting fields under types. +# Maps to: gh api orgs//issue-types and gh api orgs//issue-fields +# Arguments: +# JSON args string. +# Outputs: +# Merged JSON on stdout; gh's error text on stdout when a call fails. +# Returns: +# 0 on success, non-zero on validation, resolution, or gh failure. +####################################### +tool_issue_schema() { + local args="$1" + + local type field jq_filter max_lines suppress_errors fallback + type=$(printf '%s\n' "${args}" | jq -r '.type // empty') + field=$(printf '%s\n' "${args}" | jq -r '.field // empty') + jq_filter=$(printf '%s\n' "${args}" | jq -r '.jq_filter // empty') + max_lines=$(printf '%s\n' "${args}" | jq -r '.max_lines // empty') + suppress_errors=$(printf '%s\n' "${args}" | jq -r '.suppress_errors // false') + fallback=$(printf '%s\n' "${args}" | jq -r '.fallback // empty') + + _gh_validate_jq_filter "${jq_filter}" || return 1 + + local org + org=$(_gh_resolve_org "${args}" "issue_schema") || { + printf '%s\n' "${org}" + return 1 + } + + local types_json fields_json + types_json=$(_gh_issue_schema_fetch "${org}" "issue-types" "${suppress_errors}") || { + [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } + printf '%s\n' "${types_json}" + return 1 + } + fields_json=$(_gh_issue_schema_fetch "${org}" "issue-fields" "${suppress_errors}") || { + [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } + printf '%s\n' "${fields_json}" + return 1 + } + + local merged + merged=$(jq -n \ + --arg org "${org}" \ + --arg type "${type}" \ + --arg field "${field}" \ + --argjson types "${types_json}" \ + --argjson fields "${fields_json}" ' + def matches($wanted): $wanted == "" or (.name | ascii_downcase) == ($wanted | ascii_downcase); + { + org: $org, + types: [$types[] | select(matches($type)) | { + id, name, description, color, is_enabled + }], + fields: [$fields[] | select(matches($field)) | { + id, name, description, data_type, visibility + } + (if (.options | type) == "array" then {options: [.options[] | {id, name, color}]} else {} end)] + }') || { + [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } + printf '%s\n' "Error: could not merge issue types and issue fields for '${org}'" + return 1 + } + + local unmatched + unmatched=$(_gh_issue_schema_unmatched "${merged}" "${type}" "${field}") + if [[ -n "${unmatched}" ]]; then + printf '%s\n' "${unmatched}" + return 1 + fi + + _gh_post_process "${merged}" "${jq_filter}" "" 0 0 false false "${max_lines}" "" || return $? +} + +####################################### +# Fetch one organization-level issue schema collection. +# Arguments: +# $1 organization login, $2 endpoint segment, $3 suppress_errors flag. +# Outputs: +# The endpoint's JSON array on stdout, or gh's error text on stdout. +# Returns: +# gh's exit status. +####################################### +_gh_issue_schema_fetch() { + local org="$1" endpoint="$2" suppress_errors="$3" + + local -a cmd=("gh" "api" "orgs/${org}/${endpoint}") + + log "INFO" "issue_schema: ${cmd[*]}" + local __raw __exit=0 + if [[ "${suppress_errors}" == "true" ]]; then + __raw=$("${cmd[@]}" 2>/dev/null) || __exit=$? + else + __raw=$("${cmd[@]}" 2>&1) || __exit=$? + fi + if [[ ${__exit} -ne 0 ]]; then + printf '%s\n' "${__raw}" + return ${__exit} + fi + printf '%s\n' "${__raw}" +} + +####################################### +# Report filters that matched nothing, so an empty list never reads as an +# organization that simply has no types or fields. +# Arguments: +# $1 merged JSON, $2 requested type name, $3 requested field name. +# Outputs: +# An error message on stdout when a requested name is absent, nothing +# otherwise. +####################################### +_gh_issue_schema_unmatched() { + local merged="$1" type="$2" field="$3" + + if [[ -n "${type}" ]]; then + local type_count + type_count=$(printf '%s\n' "${merged}" | jq '.types | length') + if [[ "${type_count}" -eq 0 ]]; then + printf '%s\n' "Error: issue type '${type}' not found. Call issue_schema without 'type' to list the available types." + return 0 + fi + fi + + if [[ -n "${field}" ]]; then + local field_count + field_count=$(printf '%s\n' "${merged}" | jq '.fields | length') + if [[ "${field_count}" -eq 0 ]]; then + printf '%s\n' "Error: issue field '${field}' not found. Call issue_schema without 'field' to list the available fields." + fi + fi +} diff --git a/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh b/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh new file mode 100644 index 0000000..8c4b64e --- /dev/null +++ b/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# Issue type and issue field write tools for gh-tooling-write MCP server +# Write: issue_type_set, issue_field_set + +####################################### +# Fetch an organization's issue types or issue fields. +# Arguments: +# $1 organization login, $2 endpoint segment ("issue-types" or "issue-fields"). +# Outputs: +# The endpoint's JSON array on stdout, or gh's error text on stdout. +# Returns: +# gh's exit status. +####################################### +_gh_issue_org_collection() { + local org="$1" endpoint="$2" + + local __raw __exit=0 + __raw=$(gh api "orgs/${org}/${endpoint}" 2>&1) || __exit=$? + printf '%s\n' "${__raw}" + return ${__exit} +} + +####################################### +# Resolve an issue type name to the organization's canonical spelling. +# Arguments: +# $1 organization login, $2 requested type name. +# Outputs: +# The canonical type name on stdout, or an error listing the available types. +# Returns: +# 0 when the name resolved, 1 otherwise. +####################################### +_gh_resolve_issue_type() { + local org="$1" wanted="$2" + + local types_json + types_json=$(_gh_issue_org_collection "${org}" "issue-types") || { + printf '%s\n' "Error: could not list issue types for '${org}': ${types_json}" + return 1 + } + + local resolved + resolved=$(printf '%s\n' "${types_json}" | jq -r --arg wanted "${wanted}" ' + [.[] | select((.name | ascii_downcase) == ($wanted | ascii_downcase)) | .name][0] // empty' 2>/dev/null) + + if [[ -z "${resolved}" ]]; then + local available + available=$(printf '%s\n' "${types_json}" | jq -r '[.[].name] | join(", ")' 2>/dev/null) + printf '%s\n' "Error: issue type '${wanted}' not found in '${org}'. Available types: ${available:-}" + return 1 + fi + + printf '%s\n' "${resolved}" +} + +####################################### +# Turn a name-keyed values object into the API's issue_field_values array. +# Resolves each field name to its numeric id and checks each value against the +# field's data type, so a bad name fails here naming the valid options rather +# than reaching GitHub, which reports a wrong option name for an unknown field. +# Arguments: +# $1 organization login, $2 values object keyed by field name. +# Outputs: +# The issue_field_values JSON array on stdout, or an error message. +# Returns: +# 0 when every entry resolved, 1 otherwise. +####################################### +_gh_resolve_issue_field_values() { + local org="$1" values="$2" + + local fields_json + fields_json=$(_gh_issue_org_collection "${org}" "issue-fields") || { + printf '%s\n' "Error: could not list issue fields for '${org}': ${fields_json}" + return 1 + } + + local resolved + resolved=$(jq -n \ + --argjson fields "${fields_json}" \ + --argjson values "${values}" ' + def find($name): [$fields[] | select((.name | ascii_downcase) == ($name | ascii_downcase))][0]; + def option($field; $value): + [$field.options[] | select((.name | ascii_downcase) == ($value | ascii_downcase)) | .name][0]; + def check($name; $value): + find($name) as $field + | if $field == null + then {error: "issue field \($name) not found. Available fields: \([$fields[].name] | join(", "))"} + elif $field.data_type == "single_select" then + (if ($value | type) != "string" then {error: "issue field \($name) takes an option name as a string, got \($value | type)"} + else option($field; $value) as $match + | if $match == null + then {error: "option \($value) not found for issue field \($name). Available options: \([$field.options[].name] | join(", "))"} + else {field_id: $field.id, value: $match} end + end) + elif $field.data_type == "multi_select" then + (if ($value | type) != "array" then {error: "issue field \($name) takes an array of option names, got \($value | type)"} + elif ([$value[] | type] | any(. != "string")) then {error: "issue field \($name) takes an array of option names as strings"} + else ([$value[] | option($field; .)] | if any(. == null) then null else . end) as $matches + | if $matches == null + then {error: "one or more options not found for issue field \($name). Available options: \([$field.options[].name] | join(", "))"} + else {field_id: $field.id, value: $matches} end + end) + elif $field.data_type == "date" then + (if ($value | type) == "string" and ($value | length) == 10 + and ($value | test("^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$")) + then {field_id: $field.id, value: $value} + else {error: "issue field \($name) takes a date as YYYY-MM-DD, got \($value | tostring)"} end) + elif $field.data_type == "number" then + (if ($value | type) == "number" + then {field_id: $field.id, value: $value} + else {error: "issue field \($name) takes a number, got \($value | type)"} end) + elif $field.data_type == "text" then + (if ($value | type) == "string" + then {field_id: $field.id, value: $value} + else {error: "issue field \($name) takes a string, got \($value | type)"} end) + else + {error: "issue field \($name) has unsupported data type \($field.data_type)"} + end; + [$values | to_entries[] | check(.key; .value)] as $entries + | [$entries[] | select(has("error")) | .error] as $errors + | [$entries[] | select(has("field_id")) | .field_id] as $ids + | ([$ids[] | tostring] | group_by(.) | map(select(length > 1)) | length) as $dupes + | if ($errors | length) > 0 then {errors: $errors} + elif $dupes > 0 then {errors: ["two or more keys in values name the same issue field; pass each field once"]} + else {values: $entries} end') || { + printf '%s\n' "Error: could not read the 'values' object" + return 1 + } + + local errors + errors=$(printf '%s\n' "${resolved}" | jq -r '.errors // [] | join("; ")') + if [[ -n "${errors}" ]]; then + printf '%s\n' "Error: ${errors}" + return 1 + fi + + printf '%s\n' "${resolved}" | jq -c '.values' +} + +####################################### +# Set or clear an issue's type. +# Setting the same type twice is the same call: the type name replaces whatever +# the issue carried before, and null clears it. +# Maps to: gh api -X PATCH repos//issues/ with a type body +# Arguments: +# JSON args string. +# Outputs: +# The issue's number and resulting type as JSON, or an error message. +# Returns: +# 0 on success, non-zero on validation or gh failure. +####################################### +tool_issue_type_set() { + local args="$1" + + local number has_type type_is_null type repo suppress_errors fallback + number=$(printf '%s\n' "${args}" | jq -r '.number // empty') + has_type=$(printf '%s\n' "${args}" | jq -r 'if has("type") then "true" else "false" end') + type_is_null=$(printf '%s\n' "${args}" | jq -r 'if .type == null then "true" else "false" end') + type=$(printf '%s\n' "${args}" | jq -r '.type // empty') + repo=$(printf '%s\n' "${args}" | jq -r '.repo // empty') + suppress_errors=$(printf '%s\n' "${args}" | jq -r '.suppress_errors // false') + fallback=$(printf '%s\n' "${args}" | jq -r '.fallback // empty') + + if [[ -z "${number}" ]]; then printf '%s\n' "Error: number is required for issue_type_set"; return 1; fi + if [[ "${has_type}" != "true" ]]; then + printf '%s\n' "Error: type is required for issue_type_set. Pass an issue type name, or null to clear the type." + return 1 + fi + _gh_validate_number "${number}" "number" || return 1 + + local effective_repo + effective_repo=$(_gh_resolve_repo "${repo}") + if [[ -z "${effective_repo}" ]]; then + printf '%s\n' "Error: repo is required for issue_type_set" + return 1 + fi + _gh_validate_repo "${effective_repo}" || return 1 + + local body + if [[ "${type_is_null}" == "true" ]]; then + body='{"type":null}' + else + if [[ -z "${type}" ]]; then + printf '%s\n' "Error: type is required for issue_type_set. Pass an issue type name, or null to clear the type." + return 1 + fi + + local canonical + canonical=$(_gh_resolve_issue_type "${effective_repo%%/*}" "${type}") || { + printf '%s\n' "${canonical}" + return 1 + } + body=$(jq -nc --arg type "${canonical}" '{type: $type}') + fi + + _gh_issue_schema_write "PATCH" "repos/${effective_repo}/issues/${number}" "${body}" \ + '{number: .number, type: (.type.name // null)}' "issue_type_set" "${suppress_errors}" "${fallback}" +} + +####################################### +# Replace an issue's field values with the given set. +# The object passed becomes the issue's complete set of field values: a field +# left out is cleared, and an empty object clears every value. Sending the same +# object twice leaves the issue in the same state. +# Maps to: gh api -X PUT repos//issues//issue-field-values +# Arguments: +# JSON args string. +# Outputs: +# The resulting field values as JSON, keyed by field name so the response can +# be passed straight back as the next call's 'values', or an error message. +# Returns: +# 0 on success, non-zero on validation or gh failure. +####################################### +tool_issue_field_set() { + local args="$1" + + local number values_type values repo suppress_errors fallback + number=$(printf '%s\n' "${args}" | jq -r '.number // empty') + values_type=$(printf '%s\n' "${args}" | jq -r 'if has("values") then (.values | type) else "absent" end') + values=$(printf '%s\n' "${args}" | jq -c '.values') + repo=$(printf '%s\n' "${args}" | jq -r '.repo // empty') + suppress_errors=$(printf '%s\n' "${args}" | jq -r '.suppress_errors // false') + fallback=$(printf '%s\n' "${args}" | jq -r '.fallback // empty') + + if [[ -z "${number}" ]]; then printf '%s\n' "Error: number is required for issue_field_set"; return 1; fi + if [[ "${values_type}" == "absent" ]]; then + printf '%s\n' "Error: values is required for issue_field_set. Pass the complete set of field values, or {} to clear them all." + return 1 + fi + if [[ "${values_type}" != "object" ]]; then + printf '%s\n' "Error: values must be an object keyed by field name, got ${values_type}. Pass {} to clear every field value." + return 1 + fi + _gh_validate_number "${number}" "number" || return 1 + + local effective_repo + effective_repo=$(_gh_resolve_repo "${repo}") + if [[ -z "${effective_repo}" ]]; then + printf '%s\n' "Error: repo is required for issue_field_set" + return 1 + fi + _gh_validate_repo "${effective_repo}" || return 1 + + local field_values + field_values=$(_gh_resolve_issue_field_values "${effective_repo%%/*}" "${values}") || { + printf '%s\n' "${field_values}" + return 1 + } + + local body + body=$(jq -nc --argjson entries "${field_values}" '{issue_field_values: $entries}') || { + printf '%s\n' "Error: could not build the request body for issue_field_set" + return 1 + } + + _gh_issue_schema_write "PUT" "repos/${effective_repo}/issues/${number}/issue-field-values" \ + "${body}" '[.[] | {key: .issue_field_name, value: ( + if .multi_select_options != null then [.multi_select_options[].name] + elif .single_select_option != null then .single_select_option.name + else .value end)}] | from_entries' \ + "issue_field_set" "${suppress_errors}" "${fallback}" +} + +####################################### +# Send a request body to the GitHub API and shape the response. +# Arguments: +# $1 HTTP method, $2 endpoint, $3 request body JSON, $4 jq filter for the +# response, $5 tool name, $6 suppress_errors, $7 fallback. +# Outputs: +# The filtered response on stdout, or gh's error text on stdout. +# Returns: +# 0 on success, gh's exit status on failure. +####################################### +_gh_issue_schema_write() { + local method="$1" endpoint="$2" body="$3" response_filter="$4" + local tool="$5" suppress_errors="$6" fallback="$7" + + local -a cmd=("gh" "api" "-X" "${method}" "${endpoint}" "--input" "-") + + log "INFO" "${tool}: ${cmd[*]} ${body}" + local __raw __exit=0 + if [[ "${suppress_errors}" == "true" ]]; then + __raw=$(printf '%s' "${body}" | "${cmd[@]}" 2>/dev/null) || __exit=$? + else + __raw=$(printf '%s' "${body}" | "${cmd[@]}" 2>&1) || __exit=$? + fi + if [[ ${__exit} -ne 0 ]]; then + [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } + printf '%s\n' "${__raw}" + return ${__exit} + fi + + printf '%s\n' "${__raw}" | jq "${response_filter}" 2>/dev/null || printf '%s\n' "${__raw}" +} diff --git a/plugins/github-mcp/mcp-server-gh/server-read.sh b/plugins/github-mcp/mcp-server-gh/server-read.sh index 309d23f..d1c63fc 100755 --- a/plugins/github-mcp/mcp-server-gh/server-read.sh +++ b/plugins/github-mcp/mcp-server-gh/server-read.sh @@ -91,6 +91,7 @@ export GH_DEFAULT_REPO GH_TOOLING_CONFIG_FILE source "${SCRIPT_DIR}/lib/common.sh" source "${SCRIPT_DIR}/lib/pr.sh" source "${SCRIPT_DIR}/lib/issue.sh" +source "${SCRIPT_DIR}/lib/issue_schema.sh" source "${SCRIPT_DIR}/lib/run.sh" source "${SCRIPT_DIR}/lib/job.sh" source "${SCRIPT_DIR}/lib/commit.sh" diff --git a/plugins/github-mcp/mcp-server-gh/server-write.sh b/plugins/github-mcp/mcp-server-gh/server-write.sh index 35daa1f..2b7d5a3 100755 --- a/plugins/github-mcp/mcp-server-gh/server-write.sh +++ b/plugins/github-mcp/mcp-server-gh/server-write.sh @@ -108,6 +108,7 @@ source "${SCRIPT_DIR}/lib/common.sh" source "${SCRIPT_DIR}/lib/api.sh" source "${SCRIPT_DIR}/lib/pr_write.sh" source "${SCRIPT_DIR}/lib/issue_write.sh" +source "${SCRIPT_DIR}/lib/issue_schema_write.sh" source "${SCRIPT_DIR}/lib/review_write.sh" source "${SCRIPT_DIR}/lib/label.sh" source "${SCRIPT_DIR}/lib/assignee_write.sh" diff --git a/plugins/github-mcp/mcp-server-gh/tools-read.json b/plugins/github-mcp/mcp-server-gh/tools-read.json index fa87a88..37121ef 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-read.json +++ b/plugins/github-mcp/mcp-server-gh/tools-read.json @@ -454,7 +454,7 @@ }, { "name": "issue_view", - "description": "View a GitHub issue including title, body, state, labels, assignees, and milestone. Use to understand bug reports, feature requests, or task descriptions. Pass 'with_comments' to include discussion thread. Repository: pass `repo`, `repository`, or `owner`+`repo`.", + "description": "View a GitHub issue including title, body, state, labels, assignees, and milestone. Use to understand bug reports, feature requests, or task descriptions. Pass 'with_comments' to include discussion thread, or 'with_field_values' for the issue's type and field values. Repository: pass `repo`, `repository`, or `owner`+`repo`.", "inputSchema": { "type": "object", "required": [ @@ -481,6 +481,11 @@ "description": "Include issue comments in text output. Only applies when 'fields' is not set.", "default": false }, + "with_field_values": { + "type": "boolean", + "description": "Return the issue's type and field values as JSON: {\"type\": \"Bug\", \"field_values\": {\"Priority\": \"Low\"}}. The 'field_values' object is exactly what issue_field_set takes, so read it first, change the entries you want, and pass the whole object back. Output is always JSON; combine with 'fields' to add gh's own fields, and do not combine with 'with_comments'.", + "default": false + }, "jq_filter": { "type": "string", "description": "jq expression to filter/transform the output. Syntax is validated before execution. Useful for shaping JSON responses or reducing response size before the token cap." @@ -1821,6 +1826,60 @@ }, "additionalProperties": false } + }, + { + "name": "issue_schema", + "description": "List the issue types and issue fields available for an organization, including each single-select field's options. Use before setting an issue type or a field value to discover the exact names the write tools expect. Types and fields are independent: any organization field can be set on an issue of any type.", + "inputSchema": { + "type": "object", + "properties": { + "org": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]*$", + "description": "Organization login. Defaults to the owner of the resolved repository." + }, + "owner": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]*$", + "description": "Repository owner, used as the organization when 'org' is not set." + }, + "repo": { + "type": "string", + "description": "GitHub repository in 'owner/repo' format, used to derive the organization." + }, + "repository": { + "type": "string", + "description": "GitHub repository in 'owner/repo' format. Alias of `repo`." + }, + "type": { + "type": "string", + "description": "Return only the issue type with this exact name (case-insensitive). Errors when no type matches." + }, + "field": { + "type": "string", + "description": "Return only the issue field with this exact name (case-insensitive). Errors when no field matches." + }, + "jq_filter": { + "type": "string", + "description": "jq expression to filter/transform the JSON output." + }, + "max_lines": { + "type": "integer", + "description": "Return only the first N lines of output.", + "minimum": 1 + }, + "suppress_errors": { + "type": "boolean", + "description": "Discard stderr output.", + "default": false + }, + "fallback": { + "type": "string", + "description": "Text to return if the gh command fails." + } + }, + "additionalProperties": false + } } ] } diff --git a/plugins/github-mcp/mcp-server-gh/tools-write.json b/plugins/github-mcp/mcp-server-gh/tools-write.json index 8031722..d7ac000 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-write.json +++ b/plugins/github-mcp/mcp-server-gh/tools-write.json @@ -766,6 +766,38 @@ "fallback": { "type": "string" } } } + }, + { + "name": "issue_type_set", + "description": "Set or clear an issue's type (for example 'Bug' or 'Task'). The type name replaces whatever the issue carried before; pass null to clear it. Names are matched case-insensitively against the organization's types and resolved to their canonical spelling. Use issue_schema to list the available types. Issue types apply to issues only, not pull requests.", + "inputSchema": { + "type": "object", + "required": ["number", "type"], + "properties": { + "number": { "type": ["integer", "string"], "description": "Issue number." }, + "type": { "type": ["string", "null"], "description": "Issue type name, or null to clear the issue's type." }, + "repo": { "type": "string", "pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", "description": "GitHub repository in 'owner/repo' format. Defaults to the repo configured in .mcp-gh-tooling.json." }, + "suppress_errors": { "type": "boolean", "description": "Discard stderr output. When true, gh errors are silenced.", "default": false }, + "fallback": { "type": "string", "description": "Text to return if the gh command fails (non-zero exit)." } + }, + "additionalProperties": false + } + }, + { + "name": "issue_field_set", + "description": "Replace an issue's field values with the given set. The 'values' object becomes the issue's complete set of field values: a field left out is cleared, and {} clears them all. Read the current values with issue_view and 'with_field_values' before setting one field, or the others are dropped: its 'field_values' object is this parameter's shape, so change the entries you want and pass the whole object back. Field and option names are matched case-insensitively; use issue_schema to list the available fields and options. Issue fields apply to issues only, not pull requests.", + "inputSchema": { + "type": "object", + "required": ["number", "values"], + "properties": { + "number": { "type": ["integer", "string"], "description": "Issue number." }, + "values": { "type": "object", "description": "The complete set of field values, keyed by field name: single-select takes an option name, multi-select takes an array of option names, date takes 'YYYY-MM-DD', number takes a number, text takes a string. Pass {} to clear every field value." }, + "repo": { "type": "string", "pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", "description": "GitHub repository in 'owner/repo' format. Defaults to the repo configured in .mcp-gh-tooling.json." }, + "suppress_errors": { "type": "boolean", "description": "Discard stderr output. When true, gh errors are silenced.", "default": false }, + "fallback": { "type": "string", "description": "Text to return if the gh command fails (non-zero exit)." } + }, + "additionalProperties": false + } } ] }