From f51bd45b9a7220b8857f430358dd10decaf1e769 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Wed, 22 Oct 2025 18:22:40 +0200 Subject: [PATCH 1/7] Add ai-moderator workflow (#1274) --- .github/workflows/moderator.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/moderator.yml 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 From 3ba8d4a1221dc3c3d3c1509b95594736fe2353f7 Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Thu, 23 Oct 2025 13:27:07 +0200 Subject: [PATCH 2/7] Issues consolidation (#1211) * Issues consolidation * Issues consolidation * Clarify get_review_comments description * Add get_comments method * Formatting fixes * Clarify tool description * Clarify tool descriptions --- README.md | 109 +- .../__toolsnaps__/add_issue_comment.snap | 2 +- pkg/github/__toolsnaps__/add_sub_issue.snap | 39 - pkg/github/__toolsnaps__/get_issue.snap | 30 - .../__toolsnaps__/get_issue_comments.snap | 41 - pkg/github/__toolsnaps__/issue_read.snap | 52 + .../{update_issue.snap => issue_write.snap} | 30 +- pkg/github/__toolsnaps__/list_label.snap | 6 +- pkg/github/__toolsnaps__/list_sub_issues.snap | 38 - .../__toolsnaps__/pull_request_read.snap | 5 +- .../__toolsnaps__/remove_sub_issue.snap | 35 - ...ze_sub_issue.snap => sub_issue_write.snap} | 17 +- pkg/github/issues.go | 1359 ++++++++--------- pkg/github/issues_test.go | 254 ++- pkg/github/labels.go | 65 +- pkg/github/labels_test.go | 51 - pkg/github/pullrequests.go | 7 +- pkg/github/tools.go | 12 +- 18 files changed, 969 insertions(+), 1183 deletions(-) delete mode 100644 pkg/github/__toolsnaps__/add_sub_issue.snap delete mode 100644 pkg/github/__toolsnaps__/get_issue.snap delete mode 100644 pkg/github/__toolsnaps__/get_issue_comments.snap create mode 100644 pkg/github/__toolsnaps__/issue_read.snap rename pkg/github/__toolsnaps__/{update_issue.snap => issue_write.snap} (65%) delete mode 100644 pkg/github/__toolsnaps__/list_sub_issues.snap delete mode 100644 pkg/github/__toolsnaps__/remove_sub_issue.snap rename pkg/github/__toolsnaps__/{reprioritize_sub_issue.snap => sub_issue_write.snap} (51%) diff --git a/README.md b/README.md index bdba0d146..f0df8f143 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) @@ -927,8 +905,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/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__/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_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/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/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..b82f347f8 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)), From af2e93b846c8f8935d19ecac7be74bd5bac19b7d Mon Sep 17 00:00:00 2001 From: Tom Elliott <13594679+tmelliottjr@users.noreply.github.com> Date: Thu, 23 Oct 2025 09:32:00 -0400 Subject: [PATCH 3/7] projects: add item field support (#1282) * add fields * generate docs * pr feedback --------- Co-authored-by: Tom Elliott --- README.md | 2 + .../__toolsnaps__/get_project_item.snap | 7 + .../__toolsnaps__/list_project_items.snap | 7 + pkg/github/minimal_types.go | 28 +-- pkg/github/projects.go | 169 ++++++++++++++---- pkg/github/projects_test.go | 60 ++++++- pkg/github/tools.go | 4 +- 7 files changed, 231 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index f0df8f143..ea93b8adb 100644 --- a/README.md +++ b/README.md @@ -820,6 +820,7 @@ Options are: - `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) @@ -832,6 +833,7 @@ Options are: - `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) 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__/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/minimal_types.go b/pkg/github/minimal_types.go index 766f630bb..0a02dbcf6 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -132,20 +132,20 @@ type MinimalProject struct { } 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"` + 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 []*projectV2ItemFieldValue `json:"fields,omitempty"` } // Helper functions diff --git a/pkg/github/projects.go b/pkg/github/projects.go index f7bc94677..262288f83 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) @@ -504,6 +516,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 +537,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 +553,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) @@ -877,21 +909,53 @@ 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"` + 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 []*projectV2ItemFieldValue `json:"fields,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 +969,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 +1014,56 @@ 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", "Guide for working with GitHub Projects, including listing projects, viewing fields, querying items, and updating field values.")), + 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()), + ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + owner := request.Params.Arguments["owner"] + ownerType := request.Params.Arguments["owner_type"] + + messages := []mcp.PromptMessage{ + { + Role: "user", + Content: mcp.NewTextContent("You are an assistant helping users work with GitHub Projects V2. Your role is to help them discover projects, understand project fields, query items, and update field values on project items."), + }, + { + Role: "user", + Content: mcp.NewTextContent(fmt.Sprintf("I want to work with projects owned by %s (owner_type: %s). Please help me understand what projects are available.", owner, ownerType)), + }, + { + Role: "assistant", + Content: mcp.NewTextContent(fmt.Sprintf("I'll help you explore the projects for %s. Let me start by listing the available projects.", owner)), + }, + { + Role: "user", + Content: mcp.NewTextContent("Great! Once you show me the projects, I'd like to understand the fields available in a specific project."), + }, + { + Role: "assistant", + Content: mcp.NewTextContent("Perfect! After showing you the projects, I can help you:\n\n1. 📋 List all fields in a project (using `list_project_fields`)\n2. 🔍 Get details about specific fields including their IDs, data types, and options\n3. 📊 Query project items with specific field values (using `list_project_items`)\n\nIMPORTANT: When querying project items, you must provide a list of field IDs in the 'fields' parameter to access field values. For example: fields=[\"198354254\", \"198354255\"] to get Status and Assignees. Without this parameter, only the title field is returned."), + }, + { + Role: "user", + Content: mcp.NewTextContent("How do I update field values on project items?"), + }, + { + Role: "assistant", + Content: mcp.NewTextContent("To update field values on project items, you'll use the `update_project_item` tool. Here's what you need to know:\n\n1. **Get the item_id**: Use `list_project_items` to find the internal project item ID (not the issue/PR number)\n2. **Get the field_id**: Use `list_project_fields` to find the ID of the field you want to update\n3. **Update the field**: Call `update_project_item` with:\n - project_number: The project's number\n - item_id: The internal project item ID\n - updated_field: An object with {\"id\": , \"value\": }\n\nFor single_select fields, the value should be the option name (e.g., \"In Progress\").\nFor text fields, provide a string value.\nFor number fields, provide a numeric value.\nTo clear a field, set \"value\" to null."), + }, + { + Role: "user", + Content: mcp.NewTextContent("Can you give me an example workflow for finding items and updating their status?"), + }, + { + Role: "assistant", + Content: mcp.NewTextContent(fmt.Sprintf("Absolutely! Here's a complete workflow:\n\n**Step 1: Find your project**\nUse `list_projects` with owner=\"%s\" and owner_type=\"%s\"\n\n**Step 2: Get the Status field ID**\nUse `list_project_fields` with the project_number from step 1\nLook for the field with name=\"Status\" and note its ID (e.g., 198354254)\n\n**Step 3: Query items with the Status field**\nUse `list_project_items` with fields=[\"198354254\"] to see current status values\nOptionally add a query parameter to filter items (e.g., query=\"assignee:@me\")\n\n**Step 4: Update an item's status**\nUse `update_project_item` with:\n- item_id: The ID from the item you want to update\n- updated_field: {\"id\": 198354254, \"value\": \"In Progress\"}\n\nLet me start by listing your projects now.", owner, ownerType)), + }, + } + 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/tools.go b/pkg/github/tools.go index b82f347f8..659286e02 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -327,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)), From 5ca232e45b7281a91b3061b3dfbbdc24941f8303 Mon Sep 17 00:00:00 2001 From: Tony Truong Date: Thu, 23 Oct 2025 16:25:21 +0200 Subject: [PATCH 4/7] fixing url param descriptions (#1287) * fixing url param descriptions * update desc --- docs/remote-server.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/remote-server.md b/docs/remote-server.md index 3a4ec444a..66c8be388 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,7 +19,8 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | |----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| default | Default GitHub MCP toolset (see [default toolset](../README.md#default-toolset)) | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | @@ -76,13 +77,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: From 70cb7375e5b9447b86f9b3922ce559146795f298 Mon Sep 17 00:00:00 2001 From: Tom Elliott <13594679+tmelliottjr@users.noreply.github.com> Date: Fri, 24 Oct 2025 04:47:13 -0400 Subject: [PATCH 5/7] projects: update fields and prompt (#1292) * update fields and prompt * update snapshots * update docs * pr feedback * update snapshots * update docs --- README.md | 2 +- docs/remote-server.md | 3 +- .../__toolsnaps__/update_project_item.snap | 2 +- pkg/github/minimal_types.go | 40 ---- pkg/github/projects.go | 214 ++++++++++++++---- 5 files changed, 179 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index ea93b8adb..1e37bc0e1 100644 --- a/README.md +++ b/README.md @@ -851,7 +851,7 @@ Options are: - `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) diff --git a/docs/remote-server.md b/docs/remote-server.md index 66c8be388..fa55168e5 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,8 +19,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | |----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| default | Default GitHub MCP toolset (see [default toolset](../README.md#default-toolset)) | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | -| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%2Freadonly%22%7D) | +| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | 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/minimal_types.go b/pkg/github/minimal_types.go index 0a02dbcf6..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 []*projectV2ItemFieldValue `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 262288f83..57da7de4a 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -479,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) } @@ -589,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) } @@ -691,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) } @@ -725,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") @@ -796,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) } @@ -917,20 +914,33 @@ type projectV2ItemFieldValue struct { } 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 []*projectV2ItemFieldValue `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 { @@ -1017,49 +1027,177 @@ func addOptions(s string, opts any) (string, error) { func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { return mcp.NewPrompt("ManageProjectItems", - mcp.WithPromptDescription(t("PROMPT_MANAGE_PROJECT_ITEMS_DESCRIPTION", "Guide for working with GitHub Projects, including listing projects, viewing fields, querying items, and updating field values.")), + 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: "user", - Content: mcp.NewTextContent("You are an assistant helping users work with GitHub Projects V2. Your role is to help them discover projects, understand project fields, query items, and update field values on project items."), + 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(fmt.Sprintf("I want to work with projects owned by %s (owner_type: %s). Please help me understand what projects are available.", owner, ownerType)), + 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(fmt.Sprintf("I'll help you explore the projects for %s. Let me start by listing the available projects.", owner)), + 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("Great! Once you show me the projects, I'd like to understand the fields available in a specific project."), + Content: mcp.NewTextContent("How do I update field values? What about the different field types?"), }, { - Role: "assistant", - Content: mcp.NewTextContent("Perfect! After showing you the projects, I can help you:\n\n1. 📋 List all fields in a project (using `list_project_fields`)\n2. 🔍 Get details about specific fields including their IDs, data types, and options\n3. 📊 Query project items with specific field values (using `list_project_items`)\n\nIMPORTANT: When querying project items, you must provide a list of field IDs in the 'fields' parameter to access field values. For example: fields=[\"198354254\", \"198354255\"] to get Status and Assignees. Without this parameter, only the title field is returned."), + 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("How do I update field values on project items?"), + Content: mcp.NewTextContent("Can you show me a complete workflow example?"), }, { - Role: "assistant", - Content: mcp.NewTextContent("To update field values on project items, you'll use the `update_project_item` tool. Here's what you need to know:\n\n1. **Get the item_id**: Use `list_project_items` to find the internal project item ID (not the issue/PR number)\n2. **Get the field_id**: Use `list_project_fields` to find the ID of the field you want to update\n3. **Update the field**: Call `update_project_item` with:\n - project_number: The project's number\n - item_id: The internal project item ID\n - updated_field: An object with {\"id\": , \"value\": }\n\nFor single_select fields, the value should be the option name (e.g., \"In Progress\").\nFor text fields, provide a string value.\nFor number fields, provide a numeric value.\nTo clear a field, set \"value\" to null."), + 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("Can you give me an example workflow for finding items and updating their status?"), + Content: mcp.NewTextContent("What if I need more details about the items, like recent comments or linked pull requests?"), }, { - Role: "assistant", - Content: mcp.NewTextContent(fmt.Sprintf("Absolutely! Here's a complete workflow:\n\n**Step 1: Find your project**\nUse `list_projects` with owner=\"%s\" and owner_type=\"%s\"\n\n**Step 2: Get the Status field ID**\nUse `list_project_fields` with the project_number from step 1\nLook for the field with name=\"Status\" and note its ID (e.g., 198354254)\n\n**Step 3: Query items with the Status field**\nUse `list_project_items` with fields=[\"198354254\"] to see current status values\nOptionally add a query parameter to filter items (e.g., query=\"assignee:@me\")\n\n**Step 4: Update an item's status**\nUse `update_project_item` with:\n- item_id: The ID from the item you want to update\n- updated_field: {\"id\": 198354254, \"value\": \"In Progress\"}\n\nLet me start by listing your projects now.", owner, ownerType)), + 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{ From 5e5e80ac4cf3bbde93946234e530b4f0211833c6 Mon Sep 17 00:00:00 2001 From: axel7083 <42176370+axel7083@users.noreply.github.com> Date: Fri, 24 Oct 2025 11:13:26 +0200 Subject: [PATCH 6/7] chore(mcp/server.json): improve the OCI package specification (#1217) Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> Co-authored-by: JoannaaKL --- server.json | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/server.json b/server.json index 259ae4bb7..488144a81 100644 --- a/server.json +++ b/server.json @@ -21,43 +21,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 } ] } From ff98fc4615a1a13b3ba727e6190734eb721a3310 Mon Sep 17 00:00:00 2001 From: Babbage <42345137+MattBabbage@users.noreply.github.com> Date: Mon, 27 Oct 2025 08:34:22 +0000 Subject: [PATCH 7/7] Update registry server version (#1279) * Update server version * OCI packages must not have 'registryBaseUrl' field - use canonical reference in 'identifier' instead * Remove version and add to identifier * Take latest release without suffix after ie v0.19.1 but not v0.19.1-test * Take latest release without suffix after ie v0.19.1 but not v0.19.1-test --- .github/workflows/registry-releaser.yml | 4 ++-- server.json | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) 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/server.json b/server.json index 488144a81..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" },