diff --git a/.github/workflows/moderator.yml b/.github/workflows/moderator.yml new file mode 100644 index 000000000..91638c6ac --- /dev/null +++ b/.github/workflows/moderator.yml @@ -0,0 +1,28 @@ +name: AI Moderator +on: + issues: + types: [opened] + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + spam-detection: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + models: read + contents: read + steps: + - uses: actions/checkout@v4 + - uses: github/ai-moderator@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + spam-label: 'spam' + ai-label: 'ai-generated' + minimize-detected-comments: true + enable-spam-detection: true + enable-link-spam-detection: true + enable-ai-detection: true \ No newline at end of file diff --git a/.github/workflows/registry-releaser.yml b/.github/workflows/registry-releaser.yml index 90e0650c1..7b793785d 100644 --- a/.github/workflows/registry-releaser.yml +++ b/.github/workflows/registry-releaser.yml @@ -34,7 +34,7 @@ jobs: if [[ "${{ github.ref_type }}" == "tag" ]]; then TAG="${{ github.ref_name }}" else - TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' | head -n1) + TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1) fi IMAGE="ghcr.io/github/github-mcp-server:$TAG" @@ -59,7 +59,7 @@ jobs: if [[ "${{ github.ref_type }}" == "tag" ]]; then TAG_VERSION=$(echo "${{ github.ref_name }}" | sed 's/^v//') else - LATEST_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$' | head -n 1) + LATEST_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) [ -z "$LATEST_TAG" ] && { echo "No release tag found"; exit 1; } TAG_VERSION=$(echo "$LATEST_TAG" | sed 's/^v//') echo "Using latest tag: $LATEST_TAG" diff --git a/README.md b/README.md index bdba0d146..1e37bc0e1 100644 --- a/README.md +++ b/README.md @@ -635,44 +635,48 @@ The following sets of tools are available: - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) -- **add_sub_issue** - Add sub-issue - - `issue_number`: The number of the parent issue (number, required) - - `owner`: Repository owner (string, required) - - `replace_parent`: When true, replaces the sub-issue's current parent issue (boolean, optional) - - `repo`: Repository name (string, required) - - `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required) - - **assign_copilot_to_issue** - Assign Copilot to issue - `issueNumber`: Issue number (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) -- **create_issue** - Open new issue - - `assignees`: Usernames to assign to this issue (string[], optional) - - `body`: Issue body content (string, optional) - - `labels`: Labels to apply to this issue (string[], optional) - - `milestone`: Milestone number (number, optional) - - `owner`: Repository owner (string, required) +- **get_label** - Get a specific label from a repository. + - `name`: Label name. (string, required) + - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) - - `title`: Issue title (string, required) - - `type`: Type of this issue (string, optional) -- **get_issue** - Get issue details +- **issue_read** - Get issue details - `issue_number`: The number of the issue (number, required) + - `method`: The read operation to perform on a single issue. +Options are: +1. get - Get details of a specific issue. +2. get_comments - Get issue comments. +3. get_sub_issues - Get sub-issues of the issue. +4. get_labels - Get labels assigned to the issue. + (string, required) - `owner`: The owner of the repository (string, required) - - `repo`: The name of the repository (string, required) - -- **get_issue_comments** - Get issue comments - - `issue_number`: Issue number (number, required) - - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `repo`: Repository name (string, required) + - `repo`: The name of the repository (string, required) -- **get_label** - Get a specific label from a repository. - - `name`: Label name. (string, required) - - `owner`: Repository owner (username or organization name) (string, required) +- **issue_write** - Create or update issue. + - `assignees`: Usernames to assign to this issue (string[], optional) + - `body`: Issue body content (string, optional) + - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) + - `issue_number`: Issue number to update (number, optional) + - `labels`: Labels to apply to this issue (string[], optional) + - `method`: Write operation to perform on a single issue. +Options are: +- 'create' - creates a new issue. +- 'update' - updates an existing issue. + (string, required) + - `milestone`: Milestone number (number, optional) + - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `state`: New state (string, optional) + - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) + - `title`: Issue title (string, optional) + - `type`: Type of this issue (string, optional) - **list_issue_types** - List available issue types - `owner`: The organization owner of the repository (string, required) @@ -688,32 +692,6 @@ The following sets of tools are available: - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) -- **list_label** - List labels from a repository or an issue - - `issue_number`: Issue number - if provided, lists labels on the specific issue (number, optional) - - `owner`: Repository owner (username or organization name) - required for all operations (string, required) - - `repo`: Repository name - required for all operations (string, required) - -- **list_sub_issues** - List sub-issues - - `issue_number`: Issue number (number, required) - - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (default: 1) (number, optional) - - `per_page`: Number of results per page (max 100, default: 30) (number, optional) - - `repo`: Repository name (string, required) - -- **remove_sub_issue** - Remove sub-issue - - `issue_number`: The number of the parent issue (number, required) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `sub_issue_id`: The ID of the sub-issue to remove. ID is not the same as issue number (number, required) - -- **reprioritize_sub_issue** - Reprioritize sub-issue - - `after_id`: The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified) (number, optional) - - `before_id`: The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified) (number, optional) - - `issue_number`: The number of the parent issue (number, required) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `sub_issue_id`: The ID of the sub-issue to reprioritize. ID is not the same as issue number (number, required) - - **search_issues** - Search issues - `order`: Sort order (string, optional) - `owner`: Optional repository owner. If provided with repo, only issues for this repository are listed. (string, optional) @@ -723,19 +701,20 @@ The following sets of tools are available: - `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional) - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) -- **update_issue** - Edit issue - - `assignees`: New assignees (string[], optional) - - `body`: New description (string, optional) - - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) - - `issue_number`: Issue number to update (number, required) - - `labels`: New labels (string[], optional) - - `milestone`: New milestone number (number, optional) +- **sub_issue_write** - Change sub-issue + - `after_id`: The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified) (number, optional) + - `before_id`: The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified) (number, optional) + - `issue_number`: The number of the parent issue (number, required) + - `method`: The action to perform on a single sub-issue +Options are: +- 'add' - add a sub-issue to a parent issue in a GitHub repository. +- 'remove' - remove a sub-issue from a parent issue in a GitHub repository. +- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position. + (string, required) - `owner`: Repository owner (string, required) + - `replace_parent`: When true, replaces the sub-issue's current parent issue. Use with 'add' method only. (boolean, optional) - `repo`: Repository name (string, required) - - `state`: New state (string, optional) - - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) - - `title`: New title (string, optional) - - `type`: New issue type (string, optional) + - `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required) @@ -757,8 +736,7 @@ The following sets of tools are available: - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) -- **list_label** - List labels from a repository or an issue - - `issue_number`: Issue number - if provided, lists labels on the specific issue (number, optional) +- **list_label** - List labels from a repository - `owner`: Repository owner (username or organization name) - required for all operations (string, required) - `repo`: Repository name - required for all operations (string, required) @@ -842,6 +820,7 @@ The following sets of tools are available: - `project_number`: The project's number. (number, required) - **get_project_item** - Get project item + - `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional) - `item_id`: The item's ID. (number, required) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) @@ -854,6 +833,7 @@ The following sets of tools are available: - `project_number`: The project's number. (number, required) - **list_project_items** - List project items + - `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - `per_page`: Number of results per page (max 100, default: 30) (number, optional) @@ -871,7 +851,7 @@ The following sets of tools are available: - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - `project_number`: The project's number. (number, required) - - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set "value" to null. Example: {"id": 123456, "value": "New Value"} (object, required) + - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"} (object, required) @@ -927,8 +907,9 @@ Possible options: 2. get_diff - Get the diff of a pull request. 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. - 5. get_review_comments - Get the review comments on a pull request. Use with pagination parameters to control the number of results returned. + 5. get_review_comments - Get the review comments on a pull request. They are comments made on a portion of the unified diff during a pull request review. Use with pagination parameters to control the number of results returned. 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. + 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. (string, required) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) diff --git a/docs/remote-server.md b/docs/remote-server.md index 3a4ec444a..fa55168e5 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -76,13 +76,16 @@ Example: ### URL Path Parameters -The Remote GitHub MCP server also supports the URL path parameters: +The Remote GitHub MCP server supports the following URL path patterns: -- `/x/{toolset}` -- `/x/{toolset}/readonly` -- `/readonly` +- `/` - Default toolset (see ["default" toolset](../README.md#default-toolset)) +- `/readonly` - Default toolset in read-only mode +- `/x/all` - All available toolsets +- `/x/all/readonly` - All available toolsets in read-only mode +- `/x/{toolset}` - Single specific toolset +- `/x/{toolset}/readonly` - Single specific toolset in read-only mode -Note: `{toolset}` can only been a single toolset, not a comma-separated list. To combine multiple toolsets, use the `X-MCP-Toolsets` header instead. +Note: `{toolset}` can only be a single toolset, not a comma-separated list. To combine multiple toolsets, use the `X-MCP-Toolsets` header instead. Example: diff --git a/pkg/github/__toolsnaps__/add_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap index 92eeb1ce8..0672e0c3f 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment.snap @@ -3,7 +3,7 @@ "title": "Add comment to issue", "readOnlyHint": false }, - "description": "Add a comment to a specific issue in a GitHub repository.", + "description": "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments.", "inputSchema": { "properties": { "body": { diff --git a/pkg/github/__toolsnaps__/add_sub_issue.snap b/pkg/github/__toolsnaps__/add_sub_issue.snap deleted file mode 100644 index 2d462bcaf..000000000 --- a/pkg/github/__toolsnaps__/add_sub_issue.snap +++ /dev/null @@ -1,39 +0,0 @@ -{ - "annotations": { - "title": "Add sub-issue", - "readOnlyHint": false - }, - "description": "Add a sub-issue to a parent issue in a GitHub repository.", - "inputSchema": { - "properties": { - "issue_number": { - "description": "The number of the parent issue", - "type": "number" - }, - "owner": { - "description": "Repository owner", - "type": "string" - }, - "replace_parent": { - "description": "When true, replaces the sub-issue's current parent issue", - "type": "boolean" - }, - "repo": { - "description": "Repository name", - "type": "string" - }, - "sub_issue_id": { - "description": "The ID of the sub-issue to add. ID is not the same as issue number", - "type": "number" - } - }, - "required": [ - "owner", - "repo", - "issue_number", - "sub_issue_id" - ], - "type": "object" - }, - "name": "add_sub_issue" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_issue.snap b/pkg/github/__toolsnaps__/get_issue.snap deleted file mode 100644 index eab2b8722..000000000 --- a/pkg/github/__toolsnaps__/get_issue.snap +++ /dev/null @@ -1,30 +0,0 @@ -{ - "annotations": { - "title": "Get issue details", - "readOnlyHint": true - }, - "description": "Get details of a specific issue in a GitHub repository.", - "inputSchema": { - "properties": { - "issue_number": { - "description": "The number of the issue", - "type": "number" - }, - "owner": { - "description": "The owner of the repository", - "type": "string" - }, - "repo": { - "description": "The name of the repository", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "issue_number" - ], - "type": "object" - }, - "name": "get_issue" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_issue_comments.snap b/pkg/github/__toolsnaps__/get_issue_comments.snap deleted file mode 100644 index b28f45204..000000000 --- a/pkg/github/__toolsnaps__/get_issue_comments.snap +++ /dev/null @@ -1,41 +0,0 @@ -{ - "annotations": { - "title": "Get issue comments", - "readOnlyHint": true - }, - "description": "Get comments for a specific issue in a GitHub repository.", - "inputSchema": { - "properties": { - "issue_number": { - "description": "Issue number", - "type": "number" - }, - "owner": { - "description": "Repository owner", - "type": "string" - }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, - "repo": { - "description": "Repository name", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "issue_number" - ], - "type": "object" - }, - "name": "get_issue_comments" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_project_item.snap b/pkg/github/__toolsnaps__/get_project_item.snap index 6f8f60935..36eb7bb63 100644 --- a/pkg/github/__toolsnaps__/get_project_item.snap +++ b/pkg/github/__toolsnaps__/get_project_item.snap @@ -6,6 +6,13 @@ "description": "Get a specific Project item for a user or org", "inputSchema": { "properties": { + "fields": { + "description": "Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included.", + "items": { + "type": "string" + }, + "type": "array" + }, "item_id": { "description": "The item's ID.", "type": "number" diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap new file mode 100644 index 000000000..9e9462df6 --- /dev/null +++ b/pkg/github/__toolsnaps__/issue_read.snap @@ -0,0 +1,52 @@ +{ + "annotations": { + "title": "Get issue details", + "readOnlyHint": true + }, + "description": "Get information about a specific issue in a GitHub repository.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The number of the issue", + "type": "number" + }, + "method": { + "description": "The read operation to perform on a single issue. \nOptions are: \n1. get - Get details of a specific issue.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues of the issue.\n4. get_labels - Get labels assigned to the issue.\n", + "enum": [ + "get", + "get_comments", + "get_sub_issues", + "get_labels" + ], + "type": "string" + }, + "owner": { + "description": "The owner of the repository", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "The name of the repository", + "type": "string" + } + }, + "required": [ + "method", + "owner", + "repo", + "issue_number" + ], + "type": "object" + }, + "name": "issue_read" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue.snap b/pkg/github/__toolsnaps__/issue_write.snap similarity index 65% rename from pkg/github/__toolsnaps__/update_issue.snap rename to pkg/github/__toolsnaps__/issue_write.snap index 5c3f0e638..12d665a25 100644 --- a/pkg/github/__toolsnaps__/update_issue.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -1,20 +1,20 @@ { "annotations": { - "title": "Edit issue", + "title": "Create or update issue.", "readOnlyHint": false }, - "description": "Update an existing issue in a GitHub repository.", + "description": "Create a new or update an existing issue in a GitHub repository.", "inputSchema": { "properties": { "assignees": { - "description": "New assignees", + "description": "Usernames to assign to this issue", "items": { "type": "string" }, "type": "array" }, "body": { - "description": "New description", + "description": "Issue body content", "type": "string" }, "duplicate_of": { @@ -26,14 +26,22 @@ "type": "number" }, "labels": { - "description": "New labels", + "description": "Labels to apply to this issue", "items": { "type": "string" }, "type": "array" }, + "method": { + "description": "Write operation to perform on a single issue.\nOptions are: \n- 'create' - creates a new issue. \n- 'update' - updates an existing issue.\n", + "enum": [ + "create", + "update" + ], + "type": "string" + }, "milestone": { - "description": "New milestone number", + "description": "Milestone number", "type": "number" }, "owner": { @@ -62,20 +70,20 @@ "type": "string" }, "title": { - "description": "New title", + "description": "Issue title", "type": "string" }, "type": { - "description": "New issue type", + "description": "Type of this issue", "type": "string" } }, "required": [ + "method", "owner", - "repo", - "issue_number" + "repo" ], "type": "object" }, - "name": "update_issue" + "name": "issue_write" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_label.snap b/pkg/github/__toolsnaps__/list_label.snap index 216b773ed..1b6c0108f 100644 --- a/pkg/github/__toolsnaps__/list_label.snap +++ b/pkg/github/__toolsnaps__/list_label.snap @@ -3,13 +3,9 @@ "title": "List labels from a repository.", "readOnlyHint": true }, - "description": "List labels from a repository or an issue", + "description": "List labels from a repository", "inputSchema": { "properties": { - "issue_number": { - "description": "Issue number - if provided, lists labels on the specific issue", - "type": "number" - }, "owner": { "description": "Repository owner (username or organization name) - required for all operations", "type": "string" diff --git a/pkg/github/__toolsnaps__/list_project_items.snap b/pkg/github/__toolsnaps__/list_project_items.snap index 09b3267f0..ebc7d17df 100644 --- a/pkg/github/__toolsnaps__/list_project_items.snap +++ b/pkg/github/__toolsnaps__/list_project_items.snap @@ -6,6 +6,13 @@ "description": "List Project items for a user or org", "inputSchema": { "properties": { + "fields": { + "description": "Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included.", + "items": { + "type": "string" + }, + "type": "array" + }, "owner": { "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", "type": "string" diff --git a/pkg/github/__toolsnaps__/list_sub_issues.snap b/pkg/github/__toolsnaps__/list_sub_issues.snap deleted file mode 100644 index 70640e270..000000000 --- a/pkg/github/__toolsnaps__/list_sub_issues.snap +++ /dev/null @@ -1,38 +0,0 @@ -{ - "annotations": { - "title": "List sub-issues", - "readOnlyHint": true - }, - "description": "List sub-issues for a specific issue in a GitHub repository.", - "inputSchema": { - "properties": { - "issue_number": { - "description": "Issue number", - "type": "number" - }, - "owner": { - "description": "Repository owner", - "type": "string" - }, - "page": { - "description": "Page number for pagination (default: 1)", - "type": "number" - }, - "per_page": { - "description": "Number of results per page (max 100, default: 30)", - "type": "number" - }, - "repo": { - "description": "Repository name", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "issue_number" - ], - "type": "object" - }, - "name": "list_sub_issues" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index fa9de698c..be9661aae 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -7,14 +7,15 @@ "inputSchema": { "properties": { "method": { - "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get the review comments on a pull request. Use with pagination parameters to control the number of results returned.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n", + "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get the review comments on a pull request. They are comments made on a portion of the unified diff during a pull request review. Use with pagination parameters to control the number of results returned.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n", "enum": [ "get", "get_diff", "get_status", "get_files", "get_review_comments", - "get_reviews" + "get_reviews", + "get_comments" ], "type": "string" }, diff --git a/pkg/github/__toolsnaps__/remove_sub_issue.snap b/pkg/github/__toolsnaps__/remove_sub_issue.snap deleted file mode 100644 index a29020099..000000000 --- a/pkg/github/__toolsnaps__/remove_sub_issue.snap +++ /dev/null @@ -1,35 +0,0 @@ -{ - "annotations": { - "title": "Remove sub-issue", - "readOnlyHint": false - }, - "description": "Remove a sub-issue from a parent issue in a GitHub repository.", - "inputSchema": { - "properties": { - "issue_number": { - "description": "The number of the parent issue", - "type": "number" - }, - "owner": { - "description": "Repository owner", - "type": "string" - }, - "repo": { - "description": "Repository name", - "type": "string" - }, - "sub_issue_id": { - "description": "The ID of the sub-issue to remove. ID is not the same as issue number", - "type": "number" - } - }, - "required": [ - "owner", - "repo", - "issue_number", - "sub_issue_id" - ], - "type": "object" - }, - "name": "remove_sub_issue" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap b/pkg/github/__toolsnaps__/sub_issue_write.snap similarity index 51% rename from pkg/github/__toolsnaps__/reprioritize_sub_issue.snap rename to pkg/github/__toolsnaps__/sub_issue_write.snap index 43c258b33..d79e723f4 100644 --- a/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap +++ b/pkg/github/__toolsnaps__/sub_issue_write.snap @@ -1,9 +1,9 @@ { "annotations": { - "title": "Reprioritize sub-issue", + "title": "Change sub-issue", "readOnlyHint": false }, - "description": "Reprioritize a sub-issue to a different position in the parent issue's sub-issue list.", + "description": "Add a sub-issue to a parent issue in a GitHub repository.", "inputSchema": { "properties": { "after_id": { @@ -18,20 +18,29 @@ "description": "The number of the parent issue", "type": "number" }, + "method": { + "description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n\t\t\t\t", + "type": "string" + }, "owner": { "description": "Repository owner", "type": "string" }, + "replace_parent": { + "description": "When true, replaces the sub-issue's current parent issue. Use with 'add' method only.", + "type": "boolean" + }, "repo": { "description": "Repository name", "type": "string" }, "sub_issue_id": { - "description": "The ID of the sub-issue to reprioritize. ID is not the same as issue number", + "description": "The ID of the sub-issue to add. ID is not the same as issue number", "type": "number" } }, "required": [ + "method", "owner", "repo", "issue_number", @@ -39,5 +48,5 @@ ], "type": "object" }, - "name": "reprioritize_sub_issue" + "name": "sub_issue_write" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_project_item.snap b/pkg/github/__toolsnaps__/update_project_item.snap index 96a8e749a..6c8648503 100644 --- a/pkg/github/__toolsnaps__/update_project_item.snap +++ b/pkg/github/__toolsnaps__/update_project_item.snap @@ -27,7 +27,7 @@ "type": "number" }, "updated_field": { - "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set \"value\" to null. Example: {\"id\": 123456, \"value\": \"New Value\"}", + "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}", "properties": {}, "type": "object" } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 1c88a9fde..370b8b4f2 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -226,13 +226,25 @@ func fragmentToIssue(fragment IssueFragment) *github.Issue { } // GetIssue creates a tool to get details of a specific issue in a GitHub repository. -func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_issue", - mcp.WithDescription(t("TOOL_GET_ISSUE_DESCRIPTION", "Get details of a specific issue in a GitHub repository.")), +func IssueRead(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("issue_read", + mcp.WithDescription(t("TOOL_ISSUE_READ_DESCRIPTION", "Get information about a specific issue in a GitHub repository.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_ISSUE_USER_TITLE", "Get issue details"), + Title: t("TOOL_ISSUE_READ_USER_TITLE", "Get issue details"), ReadOnlyHint: ToBoolPtr(true), }), + mcp.WithString("method", + mcp.Required(), + mcp.Description(`The read operation to perform on a single issue. +Options are: +1. get - Get details of a specific issue. +2. get_comments - Get issue comments. +3. get_sub_issues - Get sub-issues of the issue. +4. get_labels - Get labels assigned to the issue. +`), + + mcp.Enum("get", "get_comments", "get_sub_issues", "get_labels"), + ), mcp.WithString("owner", mcp.Required(), mcp.Description("The owner of the repository"), @@ -245,8 +257,14 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Required(), mcp.Description("The number of the issue"), ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := RequiredParam[string](request, "method") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -260,31 +278,175 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool return mcp.NewToolResultError(err.Error()), nil } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) if err != nil { return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber) + + gqlClient, err := getGQLClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to get issue: %w", err) + return nil, fmt.Errorf("failed to get GitHub graphql client: %w", err) } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to get issue: %s", string(body))), nil + switch method { + case "get": + return GetIssue(ctx, client, owner, repo, issueNumber) + case "get_comments": + return GetIssueComments(ctx, client, owner, repo, issueNumber, pagination) + case "get_sub_issues": + return GetSubIssues(ctx, client, owner, repo, issueNumber, pagination) + case "get_labels": + return GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber) + default: + return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil } + } +} - r, err := json.Marshal(issue) - if err != nil { - return nil, fmt.Errorf("failed to marshal issue: %w", err) - } +func GetIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { + issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber) + if err != nil { + return nil, fmt.Errorf("failed to get issue: %w", err) + } + defer func() { _ = resp.Body.Close() }() - return mcp.NewToolResultText(string(r)), nil + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get issue: %s", string(body))), nil + } + + r, err := json.Marshal(issue) + if err != nil { + return nil, fmt.Errorf("failed to marshal issue: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func GetIssueComments(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + opts := &github.IssueListCommentsOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + comments, resp, err := client.Issues.ListComments(ctx, owner, repo, issueNumber, opts) + if err != nil { + return nil, fmt.Errorf("failed to get issue comments: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get issue comments: %s", string(body))), nil + } + + r, err := json.Marshal(comments) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func GetSubIssues(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + opts := &github.IssueListOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + subIssues, resp, err := client.SubIssue.ListByIssue(ctx, owner, repo, int64(issueNumber), opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list sub-issues", + resp, + err, + ), nil + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list sub-issues: %s", string(body))), nil + } + + r, err := json.Marshal(subIssues) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { + // Get current labels on the issue using GraphQL + var query struct { + Repository struct { + Issue struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + } `graphql:"labels(first: 100)"` + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers + } + + if err := client.Query(ctx, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get issue labels", err), nil + } + + // Extract label information + issueLabels := make([]map[string]any, len(query.Repository.Issue.Labels.Nodes)) + for i, label := range query.Repository.Issue.Labels.Nodes { + issueLabels[i] = map[string]any{ + "id": fmt.Sprintf("%v", label.ID), + "name": string(label.Name), + "color": string(label.Color), + "description": string(label.Description), } + } + + response := map[string]any{ + "labels": issueLabels, + "totalCount": int(query.Repository.Issue.Labels.TotalCount), + } + + out, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(out)), nil + } // ListIssueTypes creates a tool to list defined issue types for an organization. This can be used to understand supported issue type values for creating or updating issues. @@ -337,7 +499,7 @@ func ListIssueTypes(getClient GetClientFn, t translations.TranslationHelperFunc) // AddIssueComment creates a tool to add a comment to an issue. func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("add_issue_comment", - mcp.WithDescription(t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to a specific issue in a GitHub repository.")), + mcp.WithDescription(t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_ADD_ISSUE_COMMENT_USER_TITLE", "Add comment to issue"), ReadOnlyHint: ToBoolPtr(false), @@ -408,14 +570,23 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc } } -// AddSubIssue creates a tool to add a sub-issue to a parent issue. -func AddSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("add_sub_issue", - mcp.WithDescription(t("TOOL_ADD_SUB_ISSUE_DESCRIPTION", "Add a sub-issue to a parent issue in a GitHub repository.")), +// SubIssueWrite creates a tool to add a sub-issue to a parent issue. +func SubIssueWrite(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("sub_issue_write", + mcp.WithDescription(t("TOOL_SUB_ISSUE_WRITE_DESCRIPTION", "Add a sub-issue to a parent issue in a GitHub repository.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_ADD_SUB_ISSUE_USER_TITLE", "Add sub-issue"), + Title: t("TOOL_SUB_ISSUE_WRITE_USER_TITLE", "Change sub-issue"), ReadOnlyHint: ToBoolPtr(false), }), + mcp.WithString("method", + mcp.Required(), + mcp.Description(`The action to perform on a single sub-issue +Options are: +- 'add' - add a sub-issue to a parent issue in a GitHub repository. +- 'remove' - remove a sub-issue from a parent issue in a GitHub repository. +- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position. + `), + ), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -433,10 +604,21 @@ func AddSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t mcp.Description("The ID of the sub-issue to add. ID is not the same as issue number"), ), mcp.WithBoolean("replace_parent", - mcp.Description("When true, replaces the sub-issue's current parent issue"), + mcp.Description("When true, replaces the sub-issue's current parent issue. Use with 'add' method only."), + ), + mcp.WithNumber("after_id", + mcp.Description("The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)"), + ), + mcp.WithNumber("before_id", + mcp.Description("The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)"), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := RequiredParam[string](request, "method") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -457,53 +639,211 @@ func AddSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t if err != nil { return mcp.NewToolResultError(err.Error()), nil } + afterID, err := OptionalIntParam(request, "after_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + beforeID, err := OptionalIntParam(request, "before_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } client, err := getClient(ctx) if err != nil { return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - subIssueRequest := github.SubIssueRequest{ - SubIssueID: int64(subIssueID), - ReplaceParent: ToBoolPtr(replaceParent), + switch strings.ToLower(method) { + case "add": + return AddSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, replaceParent) + case "remove": + // Call the remove sub-issue function + return RemoveSubIssue(ctx, client, owner, repo, issueNumber, subIssueID) + case "reprioritize": + // Call the reprioritize sub-issue function + return ReprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, afterID, beforeID) + default: + return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil } + } +} - subIssue, resp, err := client.SubIssue.Add(ctx, owner, repo, int64(issueNumber), subIssueRequest) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to add sub-issue", - resp, - err, - ), nil - } +func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int, replaceParent bool) (*mcp.CallToolResult, error) { + subIssueRequest := github.SubIssueRequest{ + SubIssueID: int64(subIssueID), + ReplaceParent: ToBoolPtr(replaceParent), + } - defer func() { _ = resp.Body.Close() }() + subIssue, resp, err := client.SubIssue.Add(ctx, owner, repo, int64(issueNumber), subIssueRequest) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to add sub-issue", + resp, + err, + ), nil + } - if resp.StatusCode != http.StatusCreated { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to add sub-issue: %s", string(body))), nil - } + defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(subIssue) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to add sub-issue: %s", string(body))), nil + } - return mcp.NewToolResultText(string(r)), nil + r, err := json.Marshal(subIssue) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + +} + +func RemoveSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int) (*mcp.CallToolResult, error) { + subIssueRequest := github.SubIssueRequest{ + SubIssueID: int64(subIssueID), + } + + subIssue, resp, err := client.SubIssue.Remove(ctx, owner, repo, int64(issueNumber), subIssueRequest) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to remove sub-issue", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) } + return mcp.NewToolResultError(fmt.Sprintf("failed to remove sub-issue: %s", string(body))), nil + } + + r, err := json.Marshal(subIssue) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int, afterID int, beforeID int) (*mcp.CallToolResult, error) { + // Validate that either after_id or before_id is specified, but not both + if afterID == 0 && beforeID == 0 { + return mcp.NewToolResultError("either after_id or before_id must be specified"), nil + } + if afterID != 0 && beforeID != 0 { + return mcp.NewToolResultError("only one of after_id or before_id should be specified, not both"), nil + } + + subIssueRequest := github.SubIssueRequest{ + SubIssueID: int64(subIssueID), + } + + if afterID != 0 { + afterIDInt64 := int64(afterID) + subIssueRequest.AfterID = &afterIDInt64 + } + if beforeID != 0 { + beforeIDInt64 := int64(beforeID) + subIssueRequest.BeforeID = &beforeIDInt64 + } + + subIssue, resp, err := client.SubIssue.Reprioritize(ctx, owner, repo, int64(issueNumber), subIssueRequest) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to reprioritize sub-issue", + resp, + err, + ), nil + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to reprioritize sub-issue: %s", string(body))), nil + } + + r, err := json.Marshal(subIssue) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil } -// ListSubIssues creates a tool to list sub-issues for a GitHub issue. -func ListSubIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_sub_issues", - mcp.WithDescription(t("TOOL_LIST_SUB_ISSUES_DESCRIPTION", "List sub-issues for a specific issue in a GitHub repository.")), +// SearchIssues creates a tool to search for issues. +func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("search_issues", + mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_SUB_ISSUES_USER_TITLE", "List sub-issues"), + Title: t("TOOL_SEARCH_ISSUES_USER_TITLE", "Search issues"), ReadOnlyHint: ToBoolPtr(true), }), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Search query using GitHub issues search syntax"), + ), + mcp.WithString("owner", + mcp.Description("Optional repository owner. If provided with repo, only issues for this repository are listed."), + ), + mcp.WithString("repo", + mcp.Description("Optional repository name. If provided with owner, only issues for this repository are listed."), + ), + mcp.WithString("sort", + mcp.Description("Sort field by number of matches of categories, defaults to best match"), + mcp.Enum( + "comments", + "reactions", + "reactions-+1", + "reactions--1", + "reactions-smile", + "reactions-thinking_face", + "reactions-heart", + "reactions-tada", + "interactions", + "created", + "updated", + ), + ), + mcp.WithString("order", + mcp.Description("Sort order"), + mcp.Enum("asc", "desc"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return searchHandler(ctx, getClient, request, "issue", "failed to search issues") + } +} + +// CreateIssue creates a tool to create a new issue in a GitHub repository. +func IssueWrite(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("issue_write", + mcp.WithDescription(t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue."), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("method", + mcp.Required(), + mcp.Description(`Write operation to perform on a single issue. +Options are: +- 'create' - creates a new issue. +- 'update' - updates an existing issue. +`), + mcp.Enum("create", "update"), + ), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -513,17 +853,54 @@ func ListSubIssues(getClient GetClientFn, t translations.TranslationHelperFunc) mcp.Description("Repository name"), ), mcp.WithNumber("issue_number", - mcp.Required(), - mcp.Description("Issue number"), + mcp.Description("Issue number to update"), + ), + mcp.WithString("title", + mcp.Description("Issue title"), + ), + mcp.WithString("body", + mcp.Description("Issue body content"), + ), + mcp.WithArray("assignees", + mcp.Description("Usernames to assign to this issue"), + mcp.Items( + map[string]any{ + "type": "string", + }, + ), + ), + mcp.WithArray("labels", + mcp.Description("Labels to apply to this issue"), + mcp.Items( + map[string]any{ + "type": "string", + }, + ), + ), + mcp.WithNumber("milestone", + mcp.Description("Milestone number"), + ), + mcp.WithString("type", + mcp.Description("Type of this issue"), + ), + mcp.WithString("state", + mcp.Description("New state"), + mcp.Enum("open", "closed"), ), - mcp.WithNumber("page", - mcp.Description("Page number for pagination (default: 1)"), + mcp.WithString("state_reason", + mcp.Description("Reason for the state change. Ignored unless state is changed."), + mcp.Enum("completed", "not_planned", "duplicate"), ), - mcp.WithNumber("per_page", - mcp.Description("Number of results per page (max 100, default: 30)"), + mcp.WithNumber("duplicate_of", + mcp.Description("Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'."), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + method, err := RequiredParam[string](request, "method") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -532,105 +909,63 @@ func ListSubIssues(getClient GetClientFn, t translations.TranslationHelperFunc) if err != nil { return mcp.NewToolResultError(err.Error()), nil } - issueNumber, err := RequiredInt(request, "issue_number") + title, err := OptionalParam[string](request, "title") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - page, err := OptionalIntParamWithDefault(request, "page", 1) + + // Optional parameters + body, err := OptionalParam[string](request, "body") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - perPage, err := OptionalIntParamWithDefault(request, "per_page", 30) + + // Get assignees + assignees, err := OptionalStringArrayParam(request, "assignees") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - client, err := getClient(ctx) + // Get labels + labels, err := OptionalStringArrayParam(request, "labels") if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + return mcp.NewToolResultError(err.Error()), nil } - opts := &github.IssueListOptions{ - ListOptions: github.ListOptions{ - Page: page, - PerPage: perPage, - }, - } - - subIssues, resp, err := client.SubIssue.ListByIssue(ctx, owner, repo, int64(issueNumber), opts) + // Get optional milestone + milestone, err := OptionalIntParam(request, "milestone") if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list sub-issues", - resp, - err, - ), nil + return mcp.NewToolResultError(err.Error()), nil } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to list sub-issues: %s", string(body))), nil + var milestoneNum int + if milestone != 0 { + milestoneNum = milestone } - r, err := json.Marshal(subIssues) + // Get optional type + issueType, err := OptionalParam[string](request, "type") if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return mcp.NewToolResultError(err.Error()), nil } - return mcp.NewToolResultText(string(r)), nil - } - -} - -// RemoveSubIssue creates a tool to remove a sub-issue from a parent issue. -// Unlike other sub-issue tools, this currently uses a direct HTTP DELETE request -// because of a bug in the go-github library. -// Once the fix is released, this can be updated to use the library method. -// See: https://github.com/google/go-github/pull/3613 -func RemoveSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("remove_sub_issue", - mcp.WithDescription(t("TOOL_REMOVE_SUB_ISSUE_DESCRIPTION", "Remove a sub-issue from a parent issue in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_REMOVE_SUB_ISSUE_USER_TITLE", "Remove sub-issue"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("issue_number", - mcp.Required(), - mcp.Description("The number of the parent issue"), - ), - mcp.WithNumber("sub_issue_id", - mcp.Required(), - mcp.Description("The ID of the sub-issue to remove. ID is not the same as issue number"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") + // Handle state, state_reason and duplicateOf parameters + state, err := OptionalParam[string](request, "state") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := RequiredParam[string](request, "repo") + + stateReason, err := OptionalParam[string](request, "state_reason") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - issueNumber, err := RequiredInt(request, "issue_number") + + duplicateOf, err := OptionalIntParam(request, "duplicate_of") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - subIssueID, err := RequiredInt(request, "sub_issue_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil + if duplicateOf != 0 && stateReason != "duplicate" { + return mcp.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil } client, err := getClient(ctx) @@ -638,334 +973,195 @@ func RemoveSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - subIssueRequest := github.SubIssueRequest{ - SubIssueID: int64(subIssueID), - } - - subIssue, resp, err := client.SubIssue.Remove(ctx, owner, repo, int64(issueNumber), subIssueRequest) + gqlClient, err := getGQLClient(ctx) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to remove sub-issue", - resp, - err, - ), nil + return nil, fmt.Errorf("failed to get GraphQL client: %w", err) } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) + switch method { + case "create": + return CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType) + case "update": + issueNumber, err := RequiredInt(request, "issue_number") if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return mcp.NewToolResultError(err.Error()), nil } - return mcp.NewToolResultError(fmt.Sprintf("failed to remove sub-issue: %s", string(body))), nil - } - - r, err := json.Marshal(subIssue) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, state, stateReason, duplicateOf) + default: + return mcp.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil } - - return mcp.NewToolResultText(string(r)), nil } } -// ReprioritizeSubIssue creates a tool to reprioritize a sub-issue to a different position in the parent list. -func ReprioritizeSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("reprioritize_sub_issue", - mcp.WithDescription(t("TOOL_REPRIORITIZE_SUB_ISSUE_DESCRIPTION", "Reprioritize a sub-issue to a different position in the parent issue's sub-issue list.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_REPRIORITIZE_SUB_ISSUE_USER_TITLE", "Reprioritize sub-issue"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("issue_number", - mcp.Required(), - mcp.Description("The number of the parent issue"), - ), - mcp.WithNumber("sub_issue_id", - mcp.Required(), - mcp.Description("The ID of the sub-issue to reprioritize. ID is not the same as issue number"), - ), - mcp.WithNumber("after_id", - mcp.Description("The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)"), - ), - mcp.WithNumber("before_id", - mcp.Description("The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - issueNumber, err := RequiredInt(request, "issue_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - subIssueID, err := RequiredInt(request, "sub_issue_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Handle optional positioning parameters - afterID, err := OptionalIntParam(request, "after_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - beforeID, err := OptionalIntParam(request, "before_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +func CreateIssue(ctx context.Context, client *github.Client, owner string, repo string, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string) (*mcp.CallToolResult, error) { + if title == "" { + return mcp.NewToolResultError("missing required parameter: title"), nil + } - // Validate that either after_id or before_id is specified, but not both - if afterID == 0 && beforeID == 0 { - return mcp.NewToolResultError("either after_id or before_id must be specified"), nil - } - if afterID != 0 && beforeID != 0 { - return mcp.NewToolResultError("only one of after_id or before_id should be specified, not both"), nil - } + // Create the issue request + issueRequest := &github.IssueRequest{ + Title: github.Ptr(title), + Body: github.Ptr(body), + Assignees: &assignees, + Labels: &labels, + Milestone: &milestoneNum, + } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + if issueType != "" { + issueRequest.Type = github.Ptr(issueType) + } - subIssueRequest := github.SubIssueRequest{ - SubIssueID: int64(subIssueID), - } + issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest) + if err != nil { + return nil, fmt.Errorf("failed to create issue: %w", err) + } + defer func() { _ = resp.Body.Close() }() - if afterID != 0 { - afterIDInt64 := int64(afterID) - subIssueRequest.AfterID = &afterIDInt64 - } - if beforeID != 0 { - beforeIDInt64 := int64(beforeID) - subIssueRequest.BeforeID = &beforeIDInt64 - } + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil + } - subIssue, resp, err := client.SubIssue.Reprioritize(ctx, owner, repo, int64(issueNumber), subIssueRequest) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to reprioritize sub-issue", - resp, - err, - ), nil - } + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.GetHTMLURL(), + } - defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(minimalResponse) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to reprioritize sub-issue: %s", string(body))), nil - } + return mcp.NewToolResultText(string(r)), nil +} - r, err := json.Marshal(subIssue) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } +func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, state string, stateReason string, duplicateOf int) (*mcp.CallToolResult, error) { + // Create the issue request with only provided fields + issueRequest := &github.IssueRequest{} - return mcp.NewToolResultText(string(r)), nil - } -} + // Set optional parameters if provided + if title != "" { + issueRequest.Title = github.Ptr(title) + } -// SearchIssues creates a tool to search for issues. -func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("search_issues", - mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_SEARCH_ISSUES_USER_TITLE", "Search issues"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("query", - mcp.Required(), - mcp.Description("Search query using GitHub issues search syntax"), - ), - mcp.WithString("owner", - mcp.Description("Optional repository owner. If provided with repo, only issues for this repository are listed."), - ), - mcp.WithString("repo", - mcp.Description("Optional repository name. If provided with owner, only issues for this repository are listed."), - ), - mcp.WithString("sort", - mcp.Description("Sort field by number of matches of categories, defaults to best match"), - mcp.Enum( - "comments", - "reactions", - "reactions-+1", - "reactions--1", - "reactions-smile", - "reactions-thinking_face", - "reactions-heart", - "reactions-tada", - "interactions", - "created", - "updated", - ), - ), - mcp.WithString("order", - mcp.Description("Sort order"), - mcp.Enum("asc", "desc"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return searchHandler(ctx, getClient, request, "issue", "failed to search issues") - } -} + if body != "" { + issueRequest.Body = github.Ptr(body) + } -// CreateIssue creates a tool to create a new issue in a GitHub repository. -func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("create_issue", - mcp.WithDescription(t("TOOL_CREATE_ISSUE_DESCRIPTION", "Create a new issue in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_CREATE_ISSUE_USER_TITLE", "Open new issue"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("title", - mcp.Required(), - mcp.Description("Issue title"), - ), - mcp.WithString("body", - mcp.Description("Issue body content"), - ), - mcp.WithArray("assignees", - mcp.Description("Usernames to assign to this issue"), - mcp.Items( - map[string]any{ - "type": "string", - }, - ), - ), - mcp.WithArray("labels", - mcp.Description("Labels to apply to this issue"), - mcp.Items( - map[string]any{ - "type": "string", - }, - ), - ), - mcp.WithNumber("milestone", - mcp.Description("Milestone number"), - ), - mcp.WithString("type", - mcp.Description("Type of this issue"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - title, err := RequiredParam[string](request, "title") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + if len(labels) > 0 { + issueRequest.Labels = &labels + } - // Optional parameters - body, err := OptionalParam[string](request, "body") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + if len(assignees) > 0 { + issueRequest.Assignees = &assignees + } - // Get assignees - assignees, err := OptionalStringArrayParam(request, "assignees") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + if milestoneNum != 0 { + issueRequest.Milestone = &milestoneNum + } - // Get labels - labels, err := OptionalStringArrayParam(request, "labels") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + if issueType != "" { + issueRequest.Type = github.Ptr(issueType) + } - // Get optional milestone - milestone, err := OptionalIntParam(request, "milestone") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to update issue", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() - var milestoneNum *int - if milestone != 0 { - milestoneNum = &milestone - } + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to update issue: %s", string(body))), nil + } - // Get optional type - issueType, err := OptionalParam[string](request, "type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + // Use GraphQL API for state updates + if state != "" { + // Mandate specifying duplicateOf when trying to close as duplicate + if state == "closed" && stateReason == "duplicate" && duplicateOf == 0 { + return mcp.NewToolResultError("duplicate_of must be provided when state_reason is 'duplicate'"), nil + } - // Create the issue request - issueRequest := &github.IssueRequest{ - Title: github.Ptr(title), - Body: github.Ptr(body), - Assignees: &assignees, - Labels: &labels, - Milestone: milestoneNum, - } + // Get target issue ID (and duplicate issue ID if needed) + issueID, duplicateIssueID, err := fetchIssueIDs(ctx, gqlClient, owner, repo, issueNumber, duplicateOf) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find issues", err), nil + } - if issueType != "" { - issueRequest.Type = github.Ptr(issueType) + switch state { + case "open": + // Use ReopenIssue mutation for opening + var mutation struct { + ReopenIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + State githubv4.String + } + } `graphql:"reopenIssue(input: $input)"` } - client, err := getClient(ctx) + err = gqlClient.Mutate(ctx, &mutation, githubv4.ReopenIssueInput{ + IssueID: issueID, + }, nil) if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to reopen issue", err), nil } - issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest) - if err != nil { - return nil, fmt.Errorf("failed to create issue: %w", err) + case "closed": + // Use CloseIssue mutation for closing + var mutation struct { + CloseIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + State githubv4.String + } + } `graphql:"closeIssue(input: $input)"` } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusCreated { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil + stateReasonValue := getCloseStateReason(stateReason) + closeInput := CloseIssueInput{ + IssueID: issueID, + StateReason: &stateReasonValue, } - // Return minimal response with just essential information - minimalResponse := MinimalResponse{ - ID: fmt.Sprintf("%d", issue.GetID()), - URL: issue.GetHTMLURL(), + // Set duplicate issue ID if needed + if stateReason == "duplicate" { + closeInput.DuplicateIssueID = &duplicateIssueID } - r, err := json.Marshal(minimalResponse) + err = gqlClient.Mutate(ctx, &mutation, closeInput, nil) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to close issue", err), nil } - - return mcp.NewToolResultText(string(r)), nil } + } + + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + ID: fmt.Sprintf("%d", updatedIssue.GetID()), + URL: updatedIssue.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil } // ListIssues creates a tool to list and filter repository issues @@ -1180,337 +1376,6 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun } } -// UpdateIssue creates a tool to update an existing issue in a GitHub repository. -func UpdateIssue(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("update_issue", - mcp.WithDescription(t("TOOL_UPDATE_ISSUE_DESCRIPTION", "Update an existing issue in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_UPDATE_ISSUE_USER_TITLE", "Edit issue"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("issue_number", - mcp.Required(), - mcp.Description("Issue number to update"), - ), - mcp.WithString("title", - mcp.Description("New title"), - ), - mcp.WithString("body", - mcp.Description("New description"), - ), - mcp.WithArray("labels", - mcp.Description("New labels"), - mcp.Items( - map[string]interface{}{ - "type": "string", - }, - ), - ), - mcp.WithArray("assignees", - mcp.Description("New assignees"), - mcp.Items( - map[string]interface{}{ - "type": "string", - }, - ), - ), - mcp.WithNumber("milestone", - mcp.Description("New milestone number"), - ), - mcp.WithString("type", - mcp.Description("New issue type"), - ), - mcp.WithString("state", - mcp.Description("New state"), - mcp.Enum("open", "closed"), - ), - mcp.WithString("state_reason", - mcp.Description("Reason for the state change. Ignored unless state is changed."), - mcp.Enum("completed", "not_planned", "duplicate"), - ), - mcp.WithNumber("duplicate_of", - mcp.Description("Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'."), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - issueNumber, err := RequiredInt(request, "issue_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Create the issue request with only provided fields - issueRequest := &github.IssueRequest{} - - // Set optional parameters if provided - title, err := OptionalParam[string](request, "title") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - if title != "" { - issueRequest.Title = github.Ptr(title) - } - - body, err := OptionalParam[string](request, "body") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - if body != "" { - issueRequest.Body = github.Ptr(body) - } - - // Get labels - labels, err := OptionalStringArrayParam(request, "labels") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - if len(labels) > 0 { - issueRequest.Labels = &labels - } - - // Get assignees - assignees, err := OptionalStringArrayParam(request, "assignees") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - if len(assignees) > 0 { - issueRequest.Assignees = &assignees - } - - milestone, err := OptionalIntParam(request, "milestone") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - if milestone != 0 { - milestoneNum := milestone - issueRequest.Milestone = &milestoneNum - } - - // Get issue type - issueType, err := OptionalParam[string](request, "type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - if issueType != "" { - issueRequest.Type = github.Ptr(issueType) - } - - // Handle state, state_reason and duplicateOf parameters - state, err := OptionalParam[string](request, "state") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - stateReason, err := OptionalParam[string](request, "state_reason") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - duplicateOf, err := OptionalIntParam(request, "duplicate_of") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - if duplicateOf != 0 && stateReason != "duplicate" { - return mcp.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil - } - - // Use REST API for non-state updates - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to update issue", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to update issue: %s", string(body))), nil - } - - // Use GraphQL API for state updates - if state != "" { - gqlClient, err := getGQLClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GraphQL client: %w", err) - } - - // Mandate specifying duplicateOf when trying to close as duplicate - if state == "closed" && stateReason == "duplicate" && duplicateOf == 0 { - return mcp.NewToolResultError("duplicate_of must be provided when state_reason is 'duplicate'"), nil - } - - // Get target issue ID (and duplicate issue ID if needed) - issueID, duplicateIssueID, err := fetchIssueIDs(ctx, gqlClient, owner, repo, issueNumber, duplicateOf) - if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find issues", err), nil - } - - switch state { - case "open": - // Use ReopenIssue mutation for opening - var mutation struct { - ReopenIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - State githubv4.String - } - } `graphql:"reopenIssue(input: $input)"` - } - - err = gqlClient.Mutate(ctx, &mutation, githubv4.ReopenIssueInput{ - IssueID: issueID, - }, nil) - if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to reopen issue", err), nil - } - case "closed": - // Use CloseIssue mutation for closing - var mutation struct { - CloseIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - State githubv4.String - } - } `graphql:"closeIssue(input: $input)"` - } - - stateReasonValue := getCloseStateReason(stateReason) - closeInput := CloseIssueInput{ - IssueID: issueID, - StateReason: &stateReasonValue, - } - - // Set duplicate issue ID if needed - if stateReason == "duplicate" { - closeInput.DuplicateIssueID = &duplicateIssueID - } - - err = gqlClient.Mutate(ctx, &mutation, closeInput, nil) - if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to close issue", err), nil - } - } - } - - // Return minimal response with just essential information - minimalResponse := MinimalResponse{ - ID: fmt.Sprintf("%d", updatedIssue.GetID()), - URL: updatedIssue.GetHTMLURL(), - } - - r, err := json.Marshal(minimalResponse) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// GetIssueComments creates a tool to get comments for a GitHub issue. -func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_issue_comments", - mcp.WithDescription(t("TOOL_GET_ISSUE_COMMENTS_DESCRIPTION", "Get comments for a specific issue in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_ISSUE_COMMENTS_USER_TITLE", "Get issue comments"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("issue_number", - mcp.Required(), - mcp.Description("Issue number"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - issueNumber, err := RequiredInt(request, "issue_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - opts := &github.IssueListCommentsOptions{ - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - comments, resp, err := client.Issues.ListComments(ctx, owner, repo, issueNumber, opts) - if err != nil { - return nil, fmt.Errorf("failed to get issue comments: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to get issue comments: %s", string(body))), nil - } - - r, err := json.Marshal(comments) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - // mvpDescription is an MVP idea for generating tool descriptions from structured data in a shared format. // It is not intended for widespread usage and is not a complete implementation. type mvpDescription struct { diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index cc1923df9..1713363f6 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -22,15 +22,17 @@ import ( func Test_GetIssue(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := GetIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) + mockGQLClient := githubv4.NewClient(nil) + tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(mockGQLClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "get_issue", tool.Name) + assert.Equal(t, "issue_read", tool.Name) assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "method") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) // Setup mock issue for success case mockIssue := &github.Issue{ @@ -61,6 +63,7 @@ func Test_GetIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "get", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -77,6 +80,7 @@ func Test_GetIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "get", "owner": "owner", "repo": "repo", "issue_number": float64(999), @@ -90,7 +94,7 @@ func Test_GetIssue(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := GetIssue(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(mockGQLClient), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -130,6 +134,7 @@ func Test_AddIssueComment(t *testing.T) { assert.Equal(t, "add_issue_comment", tool.Name) assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") @@ -569,11 +574,13 @@ func Test_SearchIssues(t *testing.T) { func Test_CreateIssue(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := CreateIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) + mockGQLClient := githubv4.NewClient(nil) + tool, _ := IssueWrite(stubGetClientFn(mockClient), stubGetGQLClientFn(mockGQLClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "create_issue", tool.Name) + assert.Equal(t, "issue_write", tool.Name) assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "method") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "title") @@ -582,7 +589,7 @@ func Test_CreateIssue(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "labels") assert.Contains(t, tool.InputSchema.Properties, "milestone") assert.Contains(t, tool.InputSchema.Properties, "type") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "title"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo"}) // Setup mock issue for success case mockIssue := &github.Issue{ @@ -623,6 +630,7 @@ func Test_CreateIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "create", "owner": "owner", "repo": "repo", "title": "Test Issue", @@ -649,6 +657,7 @@ func Test_CreateIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "create", "owner": "owner", "repo": "repo", "title": "Minimal Issue", @@ -674,9 +683,10 @@ func Test_CreateIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "title": "", + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "", }, expectError: false, expectedErrMsg: "missing required parameter: title", @@ -687,7 +697,8 @@ func Test_CreateIssue(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := CreateIssue(stubGetClientFn(client), translations.NullTranslationHelper) + gqlClient := githubv4.NewClient(nil) + _, handler := IssueWrite(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1034,11 +1045,12 @@ func Test_UpdateIssue(t *testing.T) { // Verify tool definition mockClient := github.NewClient(nil) mockGQLClient := githubv4.NewClient(nil) - tool, _ := UpdateIssue(stubGetClientFn(mockClient), stubGetGQLClientFn(mockGQLClient), translations.NullTranslationHelper) + tool, _ := IssueWrite(stubGetClientFn(mockClient), stubGetGQLClientFn(mockGQLClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "update_issue", tool.Name) + assert.Equal(t, "issue_write", tool.Name) assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "method") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") @@ -1051,7 +1063,7 @@ func Test_UpdateIssue(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "state") assert.Contains(t, tool.InputSchema.Properties, "state_reason") assert.Contains(t, tool.InputSchema.Properties, "duplicate_of") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo"}) // Mock issues for reuse across test cases mockBaseIssue := &github.Issue{ @@ -1155,6 +1167,7 @@ func Test_UpdateIssue(t *testing.T) { ), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ + "method": "update", "owner": "owner", "repo": "repo", "issue_number": float64(123), @@ -1177,6 +1190,7 @@ func Test_UpdateIssue(t *testing.T) { ), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ + "method": "update", "owner": "owner", "repo": "repo", "issue_number": float64(999), @@ -1234,6 +1248,7 @@ func Test_UpdateIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "update", "owner": "owner", "repo": "repo", "issue_number": float64(123), @@ -1287,6 +1302,7 @@ func Test_UpdateIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "update", "owner": "owner", "repo": "repo", "issue_number": float64(123), @@ -1321,6 +1337,7 @@ func Test_UpdateIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "update", "owner": "owner", "repo": "repo", "issue_number": float64(999), @@ -1360,6 +1377,7 @@ func Test_UpdateIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "update", "owner": "owner", "repo": "repo", "issue_number": float64(123), @@ -1438,6 +1456,7 @@ func Test_UpdateIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "update", "owner": "owner", "repo": "repo", "issue_number": float64(123), @@ -1459,6 +1478,7 @@ func Test_UpdateIssue(t *testing.T) { mockedRESTClient: mock.NewMockedHTTPClient(), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ + "method": "update", "owner": "owner", "repo": "repo", "issue_number": float64(123), @@ -1476,7 +1496,7 @@ func Test_UpdateIssue(t *testing.T) { // Setup clients with mocks restClient := github.NewClient(tc.mockedRESTClient) gqlClient := githubv4.NewClient(tc.mockedGQLClient) - _, handler := UpdateIssue(stubGetClientFn(restClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + _, handler := IssueWrite(stubGetClientFn(restClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1496,6 +1516,10 @@ func Test_UpdateIssue(t *testing.T) { } require.NoError(t, err) + if result.IsError { + t.Fatalf("Unexpected error result: %s", getErrorResult(t, result).Text) + } + require.False(t, result.IsError) // Parse the result and get the text content @@ -1564,17 +1588,19 @@ func Test_ParseISOTimestamp(t *testing.T) { func Test_GetIssueComments(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := GetIssueComments(stubGetClientFn(mockClient), translations.NullTranslationHelper) + gqlClient := githubv4.NewClient(nil) + tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "get_issue_comments", tool.Name) + assert.Equal(t, "issue_read", tool.Name) assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "method") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "page") assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) // Setup mock comments for success case mockComments := []*github.IssueComment{ @@ -1613,6 +1639,7 @@ func Test_GetIssueComments(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "get_comments", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -1634,6 +1661,7 @@ func Test_GetIssueComments(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "get_comments", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -1652,6 +1680,7 @@ func Test_GetIssueComments(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "get_comments", "owner": "owner", "repo": "repo", "issue_number": float64(999), @@ -1665,7 +1694,8 @@ func Test_GetIssueComments(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := GetIssueComments(stubGetClientFn(client), translations.NullTranslationHelper) + gqlClient := githubv4.NewClient(nil) + _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1696,6 +1726,108 @@ func Test_GetIssueComments(t *testing.T) { } } +func Test_GetIssueLabels(t *testing.T) { + t.Parallel() + + // Verify tool definition + mockGQClient := githubv4.NewClient(nil) + mockClient := github.NewClient(nil) + tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(mockGQClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "issue_read", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "method") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "issue_number") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful issue labels listing", + requestArgs: map[string]any{ + "method": "get_labels", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + } `graphql:"labels(first: 100)"` + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "labels": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("label-1"), + "name": githubv4.String("bug"), + "color": githubv4.String("d73a4a"), + "description": githubv4.String("Something isn't working"), + }, + }, + "totalCount": githubv4.Int(1), + }, + }, + }, + }), + ), + ), + expectToolError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gqlClient := githubv4.NewClient(tc.mockedClient) + client := github.NewClient(nil) + _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + assert.NotNil(t, result) + + if tc.expectToolError { + assert.True(t, result.IsError) + if tc.expectedToolErrMsg != "" { + textContent := getErrorResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + } + } else { + assert.False(t, result.IsError) + } + }) + } +} + func TestAssignCopilotToIssue(t *testing.T) { t.Parallel() @@ -2119,17 +2251,18 @@ func TestAssignCopilotToIssue(t *testing.T) { func Test_AddSubIssue(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := AddSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "add_sub_issue", tool.Name) + assert.Equal(t, "sub_issue_write", tool.Name) assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "method") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id") assert.Contains(t, tool.InputSchema.Properties, "replace_parent") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "sub_issue_id"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}) // Setup mock issue for success case (matches GitHub API response format) mockIssue := &github.Issue{ @@ -2167,6 +2300,7 @@ func Test_AddSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "add", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2185,6 +2319,7 @@ func Test_AddSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "add", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2202,6 +2337,7 @@ func Test_AddSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "add", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2220,6 +2356,7 @@ func Test_AddSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "add", "owner": "owner", "repo": "repo", "issue_number": float64(999), @@ -2237,6 +2374,7 @@ func Test_AddSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "add", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2254,6 +2392,7 @@ func Test_AddSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "add", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2271,6 +2410,7 @@ func Test_AddSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "add", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2285,6 +2425,7 @@ func Test_AddSubIssue(t *testing.T) { // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ + "method": "add", "repo": "repo", "issue_number": float64(42), "sub_issue_id": float64(123), @@ -2298,6 +2439,7 @@ func Test_AddSubIssue(t *testing.T) { // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ + "method": "add", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2311,7 +2453,7 @@ func Test_AddSubIssue(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := AddSubIssue(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -2352,20 +2494,22 @@ func Test_AddSubIssue(t *testing.T) { } } -func Test_ListSubIssues(t *testing.T) { +func Test_GetSubIssues(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := ListSubIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper) + gqlClient := githubv4.NewClient(nil) + tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "list_sub_issues", tool.Name) + assert.Equal(t, "issue_read", tool.Name) assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "method") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "per_page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) // Setup mock sub-issues for success case mockSubIssues := []*github.Issue{ @@ -2418,6 +2562,7 @@ func Test_ListSubIssues(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "get_sub_issues", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2439,11 +2584,12 @@ func Test_ListSubIssues(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "get_sub_issues", "owner": "owner", "repo": "repo", "issue_number": float64(42), "page": float64(2), - "per_page": float64(10), + "perPage": float64(10), }, expectError: false, expectedSubIssues: mockSubIssues, @@ -2457,6 +2603,7 @@ func Test_ListSubIssues(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "get_sub_issues", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2473,6 +2620,7 @@ func Test_ListSubIssues(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "get_sub_issues", "owner": "owner", "repo": "repo", "issue_number": float64(999), @@ -2489,6 +2637,7 @@ func Test_ListSubIssues(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "get_sub_issues", "owner": "nonexistent", "repo": "repo", "issue_number": float64(42), @@ -2505,6 +2654,7 @@ func Test_ListSubIssues(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "get_sub_issues", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2518,6 +2668,7 @@ func Test_ListSubIssues(t *testing.T) { // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ + "method": "get_sub_issues", "repo": "repo", "issue_number": float64(42), }, @@ -2530,8 +2681,9 @@ func Test_ListSubIssues(t *testing.T) { // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", + "method": "get_sub_issues", + "owner": "owner", + "repo": "repo", }, expectError: false, expectedErrMsg: "missing required parameter: issue_number", @@ -2542,7 +2694,8 @@ func Test_ListSubIssues(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ListSubIssues(stubGetClientFn(client), translations.NullTranslationHelper) + gqlClient := githubv4.NewClient(nil) + _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -2595,16 +2748,17 @@ func Test_ListSubIssues(t *testing.T) { func Test_RemoveSubIssue(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := RemoveSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "remove_sub_issue", tool.Name) + assert.Equal(t, "sub_issue_write", tool.Name) assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "method") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "sub_issue_id"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}) // Setup mock issue for success case (matches GitHub API response format - the updated parent issue) mockIssue := &github.Issue{ @@ -2642,6 +2796,7 @@ func Test_RemoveSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "remove", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2659,6 +2814,7 @@ func Test_RemoveSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "remove", "owner": "owner", "repo": "repo", "issue_number": float64(999), @@ -2676,6 +2832,7 @@ func Test_RemoveSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "remove", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2693,6 +2850,7 @@ func Test_RemoveSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "remove", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2710,6 +2868,7 @@ func Test_RemoveSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "remove", "owner": "nonexistent", "repo": "repo", "issue_number": float64(42), @@ -2727,6 +2886,7 @@ func Test_RemoveSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "remove", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2741,6 +2901,7 @@ func Test_RemoveSubIssue(t *testing.T) { // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ + "method": "remove", "repo": "repo", "issue_number": float64(42), "sub_issue_id": float64(123), @@ -2754,6 +2915,7 @@ func Test_RemoveSubIssue(t *testing.T) { // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ + "method": "remove", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2767,7 +2929,7 @@ func Test_RemoveSubIssue(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := RemoveSubIssue(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -2811,18 +2973,19 @@ func Test_RemoveSubIssue(t *testing.T) { func Test_ReprioritizeSubIssue(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := ReprioritizeSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "reprioritize_sub_issue", tool.Name) + assert.Equal(t, "sub_issue_write", tool.Name) assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "method") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id") assert.Contains(t, tool.InputSchema.Properties, "after_id") assert.Contains(t, tool.InputSchema.Properties, "before_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "sub_issue_id"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}) // Setup mock issue for success case (matches GitHub API response format - the updated parent issue) mockIssue := &github.Issue{ @@ -2860,6 +3023,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "reprioritize", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2878,6 +3042,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "reprioritize", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2893,6 +3058,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ + "method": "reprioritize", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2907,6 +3073,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ + "method": "reprioritize", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2926,6 +3093,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "reprioritize", "owner": "owner", "repo": "repo", "issue_number": float64(999), @@ -2944,6 +3112,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "reprioritize", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2962,6 +3131,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "reprioritize", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2980,6 +3150,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "reprioritize", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -2998,6 +3169,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "reprioritize", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -3013,6 +3185,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ + "method": "reprioritize", "repo": "repo", "issue_number": float64(42), "sub_issue_id": float64(123), @@ -3027,6 +3200,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ + "method": "reprioritize", "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -3041,7 +3215,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ReprioritizeSubIssue(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) diff --git a/pkg/github/labels.go b/pkg/github/labels.go index f0cc0e630..c9be7be75 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -97,11 +97,11 @@ func GetLabel(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) } } -// ListLabels lists labels from a repository or an issue +// ListLabels lists labels from a repository func ListLabels(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { return mcp.NewTool( "list_label", - mcp.WithDescription(t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository or an issue")), + mcp.WithDescription(t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository."), ReadOnlyHint: ToBoolPtr(true), @@ -114,9 +114,6 @@ func ListLabels(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun mcp.Required(), mcp.Description("Repository name - required for all operations"), ), - mcp.WithNumber("issue_number", - mcp.Description("Issue number - if provided, lists labels on the specific issue"), - ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -129,69 +126,11 @@ func ListLabels(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun return mcp.NewToolResultError(err.Error()), nil } - issueNumber, err := OptionalIntParam(request, "issue_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - client, err := getGQLClient(ctx) if err != nil { return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - if issueNumber != 0 { - // Get current labels on the issue using GraphQL - var query struct { - Repository struct { - Issue struct { - Labels struct { - Nodes []struct { - ID githubv4.ID - Name githubv4.String - Color githubv4.String - Description githubv4.String - } - TotalCount githubv4.Int - } `graphql:"labels(first: 100)"` - } `graphql:"issue(number: $issueNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - - vars := map[string]any{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers - } - - if err := client.Query(ctx, &query, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get issue labels", err), nil - } - - // Extract label information - issueLabels := make([]map[string]any, len(query.Repository.Issue.Labels.Nodes)) - for i, label := range query.Repository.Issue.Labels.Nodes { - issueLabels[i] = map[string]any{ - "id": fmt.Sprintf("%v", label.ID), - "name": string(label.Name), - "color": string(label.Color), - "description": string(label.Description), - } - } - - response := map[string]any{ - "labels": issueLabels, - "totalCount": int(query.Repository.Issue.Labels.TotalCount), - } - - out, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(out)), nil - - } - var query struct { Repository struct { Labels struct { diff --git a/pkg/github/labels_test.go b/pkg/github/labels_test.go index 96b9f7f85..6bb91da26 100644 --- a/pkg/github/labels_test.go +++ b/pkg/github/labels_test.go @@ -150,7 +150,6 @@ func TestListLabels(t *testing.T) { assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) tests := []struct { @@ -210,56 +209,6 @@ func TestListLabels(t *testing.T) { ), expectToolError: false, }, - { - name: "successful issue labels listing", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - Labels struct { - Nodes []struct { - ID githubv4.ID - Name githubv4.String - Color githubv4.String - Description githubv4.String - } - TotalCount githubv4.Int - } `graphql:"labels(first: 100)"` - } `graphql:"issue(number: $issueNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "issueNumber": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "labels": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("label-1"), - "name": githubv4.String("bug"), - "color": githubv4.String("d73a4a"), - "description": githubv4.String("Something isn't working"), - }, - }, - "totalCount": githubv4.Int(1), - }, - }, - }, - }), - ), - ), - expectToolError: false, - }, } for _, tc := range tests { diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 766f630bb..f69fe423a 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -131,23 +131,6 @@ type MinimalProject struct { DeletedBy *MinimalUser `json:"deleted_by,omitempty"` } -type MinimalProjectItem struct { - ID *int64 `json:"id,omitempty"` - NodeID *string `json:"node_id,omitempty"` - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - ProjectNodeID *string `json:"project_node_id,omitempty"` - ContentNodeID *string `json:"content_node_id,omitempty"` - ProjectURL *string `json:"project_url,omitempty"` - ContentType *string `json:"content_type,omitempty"` - Creator *MinimalUser `json:"creator,omitempty"` - CreatedAt *github.Timestamp `json:"created_at,omitempty"` - UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` - ArchivedAt *github.Timestamp `json:"archived_at,omitempty"` - ItemURL *string `json:"item_url,omitempty"` - Fields []*projectV2Field `json:"fields,omitempty"` -} - // Helper functions func convertToMinimalProject(fullProject *github.ProjectV2) *MinimalProject { @@ -186,29 +169,6 @@ func convertToMinimalUser(user *github.User) *MinimalUser { } } -func convertToMinimalProjectItem(item *projectV2Item) *MinimalProjectItem { - if item == nil { - return nil - } - - return &MinimalProjectItem{ - ID: item.ID, - NodeID: item.NodeID, - Title: item.Title, - Description: item.Description, - ProjectNodeID: item.ProjectNodeID, - ContentNodeID: item.ContentNodeID, - ProjectURL: item.ProjectURL, - ContentType: item.ContentType, - Creator: convertToMinimalUser(item.Creator), - CreatedAt: item.CreatedAt, - UpdatedAt: item.UpdatedAt, - ArchivedAt: item.ArchivedAt, - ItemURL: item.ItemURL, - Fields: item.Fields, - } -} - // convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit { minimalCommit := MinimalCommit{ diff --git a/pkg/github/projects.go b/pkg/github/projects.go index f7bc94677..57da7de4a 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -76,11 +76,11 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( projects := []github.ProjectV2{} minimalProjects := []MinimalProject{} - opts := listProjectsOptions{PerPage: perPage} - - if queryStr != "" { - opts.Query = queryStr + opts := listProjectsOptions{ + paginationOptions: paginationOptions{PerPage: perPage}, + filterQueryOptions: filterQueryOptions{Query: queryStr}, } + url, err = addOptions(url, opts) if err != nil { return nil, fmt.Errorf("failed to add options to request: %w", err) @@ -257,7 +257,8 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu } projectFields := []projectV2Field{} - opts := listProjectsOptions{PerPage: perPage} + opts := paginationOptions{PerPage: perPage} + url, err = addOptions(url, opts) if err != nil { return nil, fmt.Errorf("failed to add options to request: %w", err) @@ -402,6 +403,10 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)"), ), + mcp.WithArray("fields", + mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."), + mcp.WithStringItems(), + ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { @@ -423,6 +428,11 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } + fields, err := OptionalStringArrayParam(req, "fields") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -436,10 +446,12 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun } projectItems := []projectV2Item{} - opts := listProjectsOptions{PerPage: perPage} - if queryStr != "" { - opts.Query = queryStr + opts := listProjectItemsOptions{ + paginationOptions: paginationOptions{PerPage: perPage}, + filterQueryOptions: filterQueryOptions{Query: queryStr}, + fieldSelectionOptions: fieldSelectionOptions{Fields: fields}, } + url, err = addOptions(url, opts) if err != nil { return nil, fmt.Errorf("failed to add options to request: %w", err) @@ -467,11 +479,8 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun } return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectListFailedError, string(body))), nil } - minimalProjectItems := []MinimalProjectItem{} - for _, item := range projectItems { - minimalProjectItems = append(minimalProjectItems, *convertToMinimalProjectItem(&item)) - } - r, err := json.Marshal(minimalProjectItems) + + r, err := json.Marshal(projectItems) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -504,6 +513,10 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) mcp.Required(), mcp.Description("The item's ID."), ), + mcp.WithArray("fields", + mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."), + mcp.WithStringItems(), + ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { @@ -521,6 +534,10 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) if err != nil { return mcp.NewToolResultError(err.Error()), nil } + fields, err := OptionalStringArrayParam(req, "fields") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } client, err := getClient(ctx) if err != nil { @@ -533,6 +550,18 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) } else { url = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) } + + opts := fieldSelectionOptions{} + + if len(fields) > 0 { + opts.Fields = fields + } + + url, err = addOptions(url, opts) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + projectItem := projectV2Item{} httpRequest, err := client.NewRequest("GET", url, nil) @@ -557,7 +586,7 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) } return mcp.NewToolResultError(fmt.Sprintf("failed to get project item: %s", string(body))), nil } - r, err := json.Marshal(convertToMinimalProjectItem(&projectItem)) + r, err := json.Marshal(projectItem) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -659,7 +688,7 @@ func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) } return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectAddFailedError, string(body))), nil } - r, err := json.Marshal(convertToMinimalProjectItem(&addedItem)) + r, err := json.Marshal(addedItem) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -693,7 +722,7 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu ), mcp.WithObject("updated_field", mcp.Required(), - mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set \"value\" to null. Example: {\"id\": 123456, \"value\": \"New Value\"}"), + mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}"), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") @@ -764,7 +793,7 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu } return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectUpdateFailedError, string(body))), nil } - r, err := json.Marshal(convertToMinimalProjectItem(&updatedItem)) + r, err := json.Marshal(updatedItem) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -877,21 +906,66 @@ type projectV2Field struct { UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` // The time when this field was last updated. } +type projectV2ItemFieldValue struct { + ID *int64 `json:"id,omitempty"` // The unique identifier for this field. + Name string `json:"name,omitempty"` // The display name of the field. + DataType string `json:"data_type,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select"). + Value interface{} `json:"value,omitempty"` // The value of the field for a specific project item. +} + type projectV2Item struct { - ID *int64 `json:"id,omitempty"` - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - NodeID *string `json:"node_id,omitempty"` - ProjectNodeID *string `json:"project_node_id,omitempty"` - ContentNodeID *string `json:"content_node_id,omitempty"` - ProjectURL *string `json:"project_url,omitempty"` - ContentType *string `json:"content_type,omitempty"` - Creator *github.User `json:"creator,omitempty"` - CreatedAt *github.Timestamp `json:"created_at,omitempty"` - UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` - ArchivedAt *github.Timestamp `json:"archived_at,omitempty"` - ItemURL *string `json:"item_url,omitempty"` - Fields []*projectV2Field `json:"fields,omitempty"` + ArchivedAt *github.Timestamp `json:"archived_at,omitempty"` + Content *projectV2ItemContent `json:"content,omitempty"` + ContentType *string `json:"content_type,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + Creator *github.User `json:"creator,omitempty"` + Description *string `json:"description,omitempty"` + Fields []*projectV2ItemFieldValue `json:"fields,omitempty"` + ID *int64 `json:"id,omitempty"` + ItemURL *string `json:"item_url,omitempty"` + NodeID *string `json:"node_id,omitempty"` + ProjectURL *string `json:"project_url,omitempty"` + Title *string `json:"title,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` +} + +type projectV2ItemContent struct { + Body *string `json:"body,omitempty"` + ClosedAt *github.Timestamp `json:"closed_at,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + ID *int64 `json:"id,omitempty"` + Number *int `json:"number,omitempty"` + Repository MinimalRepository `json:"repository,omitempty"` + State *string `json:"state,omitempty"` + StateReason *string `json:"stateReason,omitempty"` + Title *string `json:"title,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` + URL *string `json:"url,omitempty"` +} + +type paginationOptions struct { + PerPage int `url:"per_page,omitempty"` +} + +type filterQueryOptions struct { + Query string `url:"q,omitempty"` +} + +type fieldSelectionOptions struct { + // Specific list of field IDs to include in the response. If not provided, only the title field is included. + // Example: fields=102589,985201,169875 or fields[]=102589&fields[]=985201&fields[]=169875 + Fields []string `url:"fields,omitempty"` +} + +type listProjectsOptions struct { + paginationOptions + filterQueryOptions +} + +type listProjectItemsOptions struct { + paginationOptions + filterQueryOptions + fieldSelectionOptions } func toNewProjectType(projType string) string { @@ -905,14 +979,6 @@ func toNewProjectType(projType string) string { } } -type listProjectsOptions struct { - // For paginated result sets, the number of results to include per page. - PerPage int `url:"per_page,omitempty"` - - // Query Limit results to projects of the specified type. - Query string `url:"q,omitempty"` -} - func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { if input == nil { return nil, fmt.Errorf("updated_field must be an object") @@ -958,3 +1024,184 @@ func addOptions(s string, opts any) (string, error) { u.RawQuery = qs.Encode() return u.String(), nil } + +func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { + return mcp.NewPrompt("ManageProjectItems", + mcp.WithPromptDescription(t("PROMPT_MANAGE_PROJECT_ITEMS_DESCRIPTION", "Interactive guide for managing GitHub Projects V2, including discovery, field management, querying, and updates.")), + mcp.WithArgument("owner", mcp.ArgumentDescription("The owner of the project (user or organization name)"), mcp.RequiredArgument()), + mcp.WithArgument("owner_type", mcp.ArgumentDescription("Type of owner: 'user' or 'org'"), mcp.RequiredArgument()), + mcp.WithArgument("task", mcp.ArgumentDescription("Optional: specific task to focus on (e.g., 'discover_projects', 'update_items', 'create_reports')")), + ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + owner := request.Params.Arguments["owner"] + ownerType := request.Params.Arguments["owner_type"] + + task := "" + if t, exists := request.Params.Arguments["task"]; exists { + task = fmt.Sprintf("%v", t) + } + + messages := []mcp.PromptMessage{ + { + Role: "system", + Content: mcp.NewTextContent("You are a GitHub Projects V2 management assistant. Your expertise includes:\n\n" + + "**Core Capabilities:**\n" + + "- Project discovery and field analysis\n" + + "- Item querying with advanced filters\n" + + "- Field value updates and management\n" + + "- Progress reporting and insights\n\n" + + "**Key Rules:**\n" + + "- ALWAYS use the 'query' parameter in **list_project_items** to filter results effectively\n" + + "- ALWAYS include 'fields' parameter with specific field IDs to retrieve field values\n" + + "- Use proper field IDs (not names) when updating items\n" + + "- Provide step-by-step workflows with concrete examples\n\n" + + "**Understanding Project Items:**\n" + + "- Project items reference underlying content (issues or pull requests)\n" + + "- Project tools provide: project fields, item metadata, and basic content info\n" + + "- For detailed information about an issue or pull request (comments, events, etc.), use issue/PR specific tools\n" + + "- The 'content' field in project items includes: repository, issue/PR number, title, state\n" + + "- Use this info to fetch full details: **get_issue**, **list_comments**, **list_issue_events**\n\n" + + "**Available Tools:**\n" + + "- **list_projects**: Discover available projects\n" + + "- **get_project**: Get detailed project information\n" + + "- **list_project_fields**: Get field definitions and IDs\n" + + "- **list_project_items**: Query items with filters and field selection\n" + + "- **get_project_item**: Get specific item details\n" + + "- **add_project_item**: Add issues/PRs to projects\n" + + "- **update_project_item**: Update field values\n" + + "- **delete_project_item**: Remove items from projects"), + }, + { + Role: "user", + Content: mcp.NewTextContent(fmt.Sprintf("I want to work with GitHub Projects for %s (owner_type: %s).%s\n\n"+ + "Help me get started with project management tasks.", + owner, + ownerType, + func() string { + if task != "" { + return fmt.Sprintf(" I'm specifically interested in: %s.", task) + } + return "" + }())), + }, + { + Role: "assistant", + Content: mcp.NewTextContent(fmt.Sprintf("Perfect! I'll help you manage GitHub Projects for %s. Let me guide you through the essential workflows.\n\n"+ + "**🔍 Step 1: Project Discovery**\n"+ + "First, let's see what projects are available using **list_projects**.", owner)), + }, + { + Role: "user", + Content: mcp.NewTextContent("Great! After seeing the projects, I want to understand how to work with project fields and items."), + }, + { + Role: "assistant", + Content: mcp.NewTextContent("**📋 Step 2: Understanding Project Structure**\n\n" + + "Once you select a project, I'll help you:\n\n" + + "1. **Get field information** using **list_project_fields**\n" + + " - Find field IDs, names, and data types\n" + + " - Understand available options for select fields\n" + + " - Identify required vs. optional fields\n\n" + + "2. **Query project items** using **list_project_items**\n" + + " - Filter by assignees: query=\"assignee:@me\"\n" + + " - Filter by status: query=\"status:In Progress\"\n" + + " - Filter by labels: query=\"label:bug\"\n" + + " - Include specific fields: fields=[\"198354254\", \"198354255\"]\n\n" + + "**💡 Pro Tip:** Always specify the 'fields' parameter to get field values, not just titles!"), + }, + { + Role: "user", + Content: mcp.NewTextContent("How do I update field values? What about the different field types?"), + }, + { + Role: "assistant", + Content: mcp.NewTextContent("**✏️ Step 3: Updating Field Values**\n\n" + + "Use **update_project_item** with the updated_field parameter. The format varies by field type:\n\n" + + "**Text fields:**\n" + + "```json\n" + + "{\"id\": 123456, \"value\": \"Updated text content\"}\n" + + "```\n\n" + + "**Single-select fields:**\n" + + "```json\n" + + "{\"id\": 198354254, \"value\": 18498754}\n" + + "```\n" + + "*(Use option ID, not option name)*\n\n" + + "**Date fields:**\n" + + "```json\n" + + "{\"id\": 789012, \"value\": \"2024-03-15\"}\n" + + "```\n\n" + + "**Number fields:**\n" + + "```json\n" + + "{\"id\": 345678, \"value\": 5}\n" + + "```\n\n" + + "**Clear a field:**\n" + + "```json\n" + + "{\"id\": 123456, \"value\": null}\n" + + "```\n\n" + + "**⚠️ Important:** Use the internal project item_id (not issue/PR number) for updates!"), + }, + { + Role: "user", + Content: mcp.NewTextContent("Can you show me a complete workflow example?"), + }, + { + Role: "assistant", + Content: mcp.NewTextContent(fmt.Sprintf("**🔄 Complete Workflow Example**\n\n"+ + "Here's how to find and update your assigned items:\n\n"+ + "**Step 1:** Discover projects\n\n"+ + "**list_projects** owner=\"%s\" owner_type=\"%s\"\n\n\n"+ + "**Step 2:** Get project fields (using project #123)\n\n"+ + "**list_project_fields** owner=\"%s\" owner_type=\"%s\" project_number=123\n\n"+ + "*(Note the Status field ID, e.g., 198354254)*\n\n"+ + "**Step 3:** Query your assigned items\n\n"+ + "**list_project_items**\n"+ + " owner=\"%s\"\n"+ + " owner_type=\"%s\"\n"+ + " project_number=123\n"+ + " query=\"assignee:@me\"\n"+ + " fields=[\"198354254\", \"other_field_ids\"]\n\n\n"+ + "**Step 4:** Update item status\n\n"+ + "**update_project_item**\n"+ + " owner=\"%s\"\n"+ + " owner_type=\"%s\"\n"+ + " project_number=123\n"+ + " item_id=789123\n"+ + " updated_field={\"id\": 198354254, \"value\": 18498754}\n\n\n"+ + "Let me start by listing your projects now!", owner, ownerType, owner, ownerType, owner, ownerType, owner, ownerType)), + }, + { + Role: "user", + Content: mcp.NewTextContent("What if I need more details about the items, like recent comments or linked pull requests?"), + }, + { + Role: "assistant", + Content: mcp.NewTextContent("**📝 Accessing Underlying Issue/PR Details**\n\n" + + "Project items contain basic content info, but for detailed information you need to use issue/PR tools:\n\n" + + "**From project items, extract:**\n" + + "- content.repository.name and content.repository.owner.login\n" + + "- content.number (the issue/PR number)\n" + + "- content_type (\"Issue\" or \"PullRequest\")\n\n" + + "**Then use these tools for details:**\n\n" + + "1. **Get full issue/PR details:**\n" + + " - **get_issue** owner=repo_owner repo=repo_name issue_number=123\n" + + " - Returns: full body, labels, assignees, milestone, etc.\n\n" + + "2. **Get recent comments:**\n" + + " - **list_comments** owner=repo_owner repo=repo_name issue_number=123\n" + + " - Add since parameter to filter recent comments\n\n" + + "3. **Get issue events:**\n" + + " - **list_issue_events** owner=repo_owner repo=repo_name issue_number=123\n" + + " - Shows timeline: assignments, label changes, status updates\n\n" + + "4. **For pull requests specifically:**\n" + + " - **get_pull_request** owner=repo_owner repo=repo_name pull_number=123\n" + + " - **list_pull_request_reviews** for review status\n\n" + + "**💡 Example:** To check for blockers in comments:\n" + + "1. Get project items with query=\"assignee:@me is:open\"\n" + + "2. For each item, extract repository and issue number from content\n" + + "3. Use **list_comments** to get recent comments\n" + + "4. Search comments for keywords like \"blocked\", \"blocker\", \"waiting\""), + }, + } + return &mcp.GetPromptResult{ + Messages: messages, + }, nil + } +} diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 52adb73e6..a55749cc1 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -609,10 +609,14 @@ func Test_ListProjectItems(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "project_number") assert.Contains(t, tool.InputSchema.Properties, "query") assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.Contains(t, tool.InputSchema.Properties, "fields") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) orgItems := []map[string]any{ - {"id": 301, "content_type": "Issue", "project_node_id": "PR_1"}, + {"id": 301, "content_type": "Issue", "project_node_id": "PR_1", "fields": []map[string]any{ + {"id": 123, "name": "Status", "data_type": "single_select", "value": "value1"}, + {"id": 456, "name": "Priority", "data_type": "single_select", "value": "value2"}, + }}, } userItems := []map[string]any{ {"id": 401, "content_type": "PullRequest", "project_node_id": "PR_2"}, @@ -642,6 +646,32 @@ func Test_ListProjectItems(t *testing.T) { }, expectedLength: 1, }, + { + name: "success organization items with fields", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + fieldParams := q["fields"] + if len(fieldParams) == 3 && fieldParams[0] == "123" && fieldParams[1] == "456" && fieldParams[2] == "789" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgItems)) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "fields": []interface{}{"123", "456", "789"}, + }, + expectedLength: 1, + }, { name: "success user items", mockedClient: mock.NewMockedHTTPClient( @@ -775,6 +805,7 @@ func Test_GetProjectItem(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "project_number") assert.Contains(t, tool.InputSchema.Properties, "item_id") + assert.Contains(t, tool.InputSchema.Properties, "fields") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) orgItem := map[string]any{ @@ -814,6 +845,33 @@ func Test_GetProjectItem(t *testing.T) { }, expectedID: 301, }, + { + name: "success organization item with fields", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + fieldParams := q["fields"] + if len(fieldParams) == 2 && fieldParams[0] == "123" && fieldParams[1] == "456" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgItem)) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "item_id": float64(301), + "fields": []interface{}{"123", "456"}, + }, + expectedID: 301, + }, { name: "success user item", mockedClient: mock.NewMockedHTTPClient( diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 829cd56a1..a2e8805ca 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -33,11 +33,12 @@ Possible options: 2. get_diff - Get the diff of a pull request. 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. - 5. get_review_comments - Get the review comments on a pull request. Use with pagination parameters to control the number of results returned. + 5. get_review_comments - Get the review comments on a pull request. They are comments made on a portion of the unified diff during a pull request review. Use with pagination parameters to control the number of results returned. 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. + 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. `), - mcp.Enum("get", "get_diff", "get_status", "get_files", "get_review_comments", "get_reviews"), + mcp.Enum("get", "get_diff", "get_status", "get_files", "get_review_comments", "get_reviews", "get_comments"), ), mcp.WithString("owner", mcp.Required(), @@ -95,6 +96,8 @@ Possible options: return GetPullRequestReviewComments(ctx, client, owner, repo, pullNumber, pagination) case "get_reviews": return GetPullRequestReviews(ctx, client, owner, repo, pullNumber) + case "get_comments": + return GetIssueComments(ctx, client, owner, repo, pullNumber, pagination) default: return nil, fmt.Errorf("unknown method: %s", method) } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 31138258a..659286e02 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -191,23 +191,17 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG ) issues := toolsets.NewToolset(ToolsetMetadataIssues.ID, ToolsetMetadataIssues.Description). AddReadTools( - toolsets.NewServerTool(GetIssue(getClient, t)), + toolsets.NewServerTool(IssueRead(getClient, getGQLClient, t)), toolsets.NewServerTool(SearchIssues(getClient, t)), toolsets.NewServerTool(ListIssues(getGQLClient, t)), - toolsets.NewServerTool(GetIssueComments(getClient, t)), toolsets.NewServerTool(ListIssueTypes(getClient, t)), - toolsets.NewServerTool(ListSubIssues(getClient, t)), toolsets.NewServerTool(GetLabel(getGQLClient, t)), - toolsets.NewServerTool(ListLabels(getGQLClient, t)), ). AddWriteTools( - toolsets.NewServerTool(CreateIssue(getClient, t)), + toolsets.NewServerTool(IssueWrite(getClient, getGQLClient, t)), toolsets.NewServerTool(AddIssueComment(getClient, t)), - toolsets.NewServerTool(UpdateIssue(getClient, getGQLClient, t)), toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)), - toolsets.NewServerTool(AddSubIssue(getClient, t)), - toolsets.NewServerTool(RemoveSubIssue(getClient, t)), - toolsets.NewServerTool(ReprioritizeSubIssue(getClient, t)), + toolsets.NewServerTool(SubIssueWrite(getClient, t)), ).AddPrompts( toolsets.NewServerPrompt(AssignCodingAgentPrompt(t)), toolsets.NewServerPrompt(IssueToFixWorkflowPrompt(t)), @@ -333,7 +327,9 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(AddProjectItem(getClient, t)), toolsets.NewServerTool(DeleteProjectItem(getClient, t)), toolsets.NewServerTool(UpdateProjectItem(getClient, t)), - ) + ).AddPrompts( + toolsets.NewServerPrompt(ManageProjectItemsPrompt(t)), + ) stargazers := toolsets.NewToolset(ToolsetMetadataStargazers.ID, ToolsetMetadataStargazers.Description). AddReadTools( toolsets.NewServerTool(ListStarredRepositories(getClient, t)), diff --git a/server.json b/server.json index 259ae4bb7..127e4bd05 100644 --- a/server.json +++ b/server.json @@ -1,5 +1,5 @@ { - "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json", + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", "name": "io.github.github/github-mcp-server", "description": "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language.", "status": "active", @@ -11,9 +11,7 @@ "packages": [ { "registryType": "oci", - "registryBaseUrl": "https://ghcr.io/", - "identifier": "github/github-mcp-server", - "version": "${VERSION}", + "identifier": "ghcr.io/github/github-mcp-server:${VERSION}", "transport": { "type": "stdio" }, @@ -21,43 +19,44 @@ { "type": "positional", "value": "run", - "description": "The runtime command to execute" + "description": "The runtime command to execute", + "isRequired": true }, { "type": "named", "name": "-i", - "description": "Run container in interactive mode" + "value": "true", + "description": "Run container in interactive mode", + "format": "boolean", + "isRequired": true }, { "type": "named", "name": "--rm", - "description": "Automatically remove the container when it exits" + "value": "true", + "description": "Automatically remove the container when it exits", + "format": "boolean" }, { "type": "named", "name": "-e", - "description": "Set an environment variable in the runtime" - }, - { - "type": "positional", - "valueHint": "env_var_name", - "value": "GITHUB_PERSONAL_ACCESS_TOKEN", - "description": "Environment variable name" + "description": "Set an environment variable in the runtime", + "value": "GITHUB_PERSONAL_ACCESS_TOKEN={token}", + "isRequired": true, + "variables": { + "token": { + "isRequired": true, + "isSecret": true, + "format": "string" + } + } }, { "type": "positional", "valueHint": "image_name", "value": "ghcr.io/github/github-mcp-server", - "description": "The container image to run" - } - ], - "environmentVariables": [ - { - "description": "Your GitHub personal access token with appropriate scopes.", - "isRequired": true, - "format": "string", - "isSecret": true, - "name": "GITHUB_PERSONAL_ACCESS_TOKEN" + "description": "The container image to run", + "isRequired": true } ] }