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)),