diff --git a/README.md b/README.md index afe003002..5564a9129 100644 --- a/README.md +++ b/README.md @@ -808,6 +808,30 @@ The following sets of tools are available: - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) +- **issue_dependency_read** - Get issue dependencies + - **Required OAuth Scopes**: `repo` + - `issue_number`: The number of the issue (number, required) + - `method`: The read operation to perform on issue dependencies. + Options are: + - 'get_blocked_by' - Get the list of issues that block this issue. + (string, 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) + +- **issue_dependency_write** - Manage issue dependencies + - **Required OAuth Scopes**: `repo` + - `blocking_issue_id`: The ID (not number) of the issue that blocks this issue (number, required) + - `issue_number`: The number of the issue that is blocked (number, required) + - `method`: The action to perform on issue dependencies. + Options are: + - 'add' - Add a 'blocked by' relationship, indicating this issue is blocked by another issue. + - 'remove' - Remove a 'blocked by' relationship. + (string, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - **issue_read** - Get issue details - **Required OAuth Scopes**: `repo` - `issue_number`: The number of the issue (number, required) diff --git a/pkg/github/__toolsnaps__/issue_dependency_read.snap b/pkg/github/__toolsnaps__/issue_dependency_read.snap new file mode 100644 index 000000000..714e2a752 --- /dev/null +++ b/pkg/github/__toolsnaps__/issue_dependency_read.snap @@ -0,0 +1,49 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get issue dependencies" + }, + "description": "Get information about issue dependencies, such as which issues block this issue.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The number of the issue", + "type": "number" + }, + "method": { + "description": "The read operation to perform on issue dependencies.\nOptions are:\n- 'get_blocked_by' - Get the list of issues that block this issue.\n", + "enum": [ + "get_blocked_by" + ], + "type": "string" + }, + "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": [ + "method", + "owner", + "repo", + "issue_number" + ], + "type": "object" + }, + "name": "issue_dependency_read" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_dependency_write.snap b/pkg/github/__toolsnaps__/issue_dependency_write.snap new file mode 100644 index 000000000..1be701d28 --- /dev/null +++ b/pkg/github/__toolsnaps__/issue_dependency_write.snap @@ -0,0 +1,43 @@ +{ + "annotations": { + "title": "Manage issue dependencies" + }, + "description": "Manage issue dependencies by adding or removing 'blocked by' relationships between issues.", + "inputSchema": { + "properties": { + "blocking_issue_id": { + "description": "The ID (not number) of the issue that blocks this issue", + "type": "number" + }, + "issue_number": { + "description": "The number of the issue that is blocked", + "type": "number" + }, + "method": { + "description": "The action to perform on issue dependencies.\nOptions are:\n- 'add' - Add a 'blocked by' relationship, indicating this issue is blocked by another issue.\n- 'remove' - Remove a 'blocked by' relationship.\n", + "enum": [ + "add", + "remove" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "method", + "owner", + "repo", + "issue_number", + "blocking_issue_id" + ], + "type": "object" + }, + "name": "issue_dependency_write" +} \ No newline at end of file diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 0bb73008e..0653c3082 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -62,6 +62,11 @@ const ( DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue" PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority" + // Issue dependency endpoints + GetReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by" + PostReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by" + DeleteReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumberByIssueID = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by/{issue_id}" + // Pull request endpoints GetReposPullsByOwnerByRepo = "GET /repos/{owner}/{repo}/pulls" GetReposPullsByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}" diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 62e1a0bac..8216553b2 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -913,6 +913,284 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri return utils.NewToolResultText(string(r)), nil } +// IssueDependency represents a dependency relationship between issues. +type IssueDependency struct { + ID int64 `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + HTMLURL string `json:"html_url"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// IssueDependencyRead creates a tool to read issue dependency information. +func IssueDependencyRead(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `The read operation to perform on issue dependencies. +Options are: +- 'get_blocked_by' - Get the list of issues that block this issue. +`, + Enum: []any{"get_blocked_by"}, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The number of the issue", + }, + }, + Required: []string{"method", "owner", "repo", "issue_number"}, + } + WithPagination(schema) + + return NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "issue_dependency_read", + Description: t("TOOL_ISSUE_DEPENDENCY_READ_DESCRIPTION", "Get information about issue dependencies, such as which issues block this issue."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ISSUE_DEPENDENCY_READ_USER_TITLE", "Get issue dependencies"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + switch strings.ToLower(method) { + case "get_blocked_by": + result, err := GetBlockedBy(ctx, client, owner, repo, issueNumber, pagination) + return result, nil, err + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }) +} + +// GetBlockedBy retrieves the list of issues that block the specified issue. +func GetBlockedBy(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + url := fmt.Sprintf("repos/%s/%s/issues/%d/dependencies/blocked_by", owner, repo, issueNumber) + + req, err := client.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add pagination query parameters + q := req.URL.Query() + if pagination.Page > 0 { + q.Set("page", fmt.Sprintf("%d", pagination.Page)) + } + if pagination.PerPage > 0 { + q.Set("per_page", fmt.Sprintf("%d", pagination.PerPage)) + } + req.URL.RawQuery = q.Encode() + + var dependencies []IssueDependency + resp, err := client.Do(ctx, req, &dependencies) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get issue dependencies", 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 ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get issue dependencies", resp, body), nil + } + + r, err := json.Marshal(dependencies) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil +} + +// IssueDependencyWrite creates a tool to manage issue dependency relationships. +func IssueDependencyWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "issue_dependency_write", + Description: t("TOOL_ISSUE_DEPENDENCY_WRITE_DESCRIPTION", "Manage issue dependencies by adding or removing 'blocked by' relationships between issues."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ISSUE_DEPENDENCY_WRITE_USER_TITLE", "Manage issue dependencies"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `The action to perform on issue dependencies. +Options are: +- 'add' - Add a 'blocked by' relationship, indicating this issue is blocked by another issue. +- 'remove' - Remove a 'blocked by' relationship. +`, + Enum: []any{"add", "remove"}, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The number of the issue that is blocked", + }, + "blocking_issue_id": { + Type: "number", + Description: "The ID (not number) of the issue that blocks this issue", + }, + }, + Required: []string{"method", "owner", "repo", "issue_number", "blocking_issue_id"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + blockingIssueID, err := RequiredInt(args, "blocking_issue_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + switch strings.ToLower(method) { + case "add": + result, err := AddBlockedBy(ctx, client, owner, repo, issueNumber, blockingIssueID) + return result, nil, err + case "remove": + result, err := RemoveBlockedBy(ctx, client, owner, repo, issueNumber, blockingIssueID) + return result, nil, err + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }) +} + +// AddBlockedBy adds a 'blocked by' relationship to an issue. +func AddBlockedBy(ctx context.Context, client *github.Client, owner, repo string, issueNumber, blockingIssueID int) (*mcp.CallToolResult, error) { + url := fmt.Sprintf("repos/%s/%s/issues/%d/dependencies/blocked_by", owner, repo, issueNumber) + + body := struct { + IssueID int64 `json:"issue_id"` + }{ + IssueID: int64(blockingIssueID), + } + + req, err := client.NewRequest("POST", url, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(ctx, req, nil) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add issue dependency", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add issue dependency", resp, respBody), nil + } + + return utils.NewToolResultText(`{"message": "Dependency relationship created successfully"}`), nil +} + +// RemoveBlockedBy removes a 'blocked by' relationship from an issue. +func RemoveBlockedBy(ctx context.Context, client *github.Client, owner, repo string, issueNumber, blockingIssueID int) (*mcp.CallToolResult, error) { + url := fmt.Sprintf("repos/%s/%s/issues/%d/dependencies/blocked_by/%d", owner, repo, issueNumber, blockingIssueID) + + req, err := client.NewRequest("DELETE", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(ctx, req, nil) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to remove issue dependency", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + // DELETE typically returns 204 No Content on success + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to remove issue dependency", resp, respBody), nil + } + + return utils.NewToolResultText(`{"message": "Dependency relationship removed successfully"}`), nil +} + // SearchIssues creates a tool to search for issues. func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index a338efcba..8d712fa14 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -3847,3 +3847,491 @@ func Test_ListIssueTypes(t *testing.T) { }) } } + +func Test_GetBlockedBy(t *testing.T) { + // Verify tool definition once + serverTool := IssueDependencyRead(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "issue_dependency_read", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"}) + + // Setup mock dependencies for success case + mockDependencies := []IssueDependency{ + { + ID: 123, + Number: 10, + Title: "Blocking Issue 1", + State: "open", + HTMLURL: "https://github.com/owner/repo/issues/10", + }, + { + ID: 456, + Number: 20, + Title: "Blocking Issue 2", + State: "closed", + HTMLURL: "https://github.com/owner/repo/issues/20", + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedDependencies []IssueDependency + expectedErrMsg string + }{ + { + name: "successful get blocked by", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockDependencies), + }), + requestArgs: map[string]interface{}{ + "method": "get_blocked_by", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedDependencies: mockDependencies, + }, + { + name: "successful get blocked by with pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen(mockResponse(t, http.StatusOK, mockDependencies)), + }), + requestArgs: map[string]interface{}{ + "method": "get_blocked_by", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "page": float64(2), + "perPage": float64(10), + }, + expectError: false, + expectedDependencies: mockDependencies, + }, + { + name: "empty dependencies list", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []IssueDependency{}), + }), + requestArgs: map[string]interface{}{ + "method": "get_blocked_by", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedDependencies: []IssueDependency{}, + }, + { + name: "issue not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), + requestArgs: map[string]interface{}{ + "method": "get_blocked_by", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + }, + expectError: false, + expectedErrMsg: "failed to get issue dependencies", + }, + { + name: "insufficient permissions", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have read access to repository"}`), + }), + requestArgs: map[string]interface{}{ + "method": "get_blocked_by", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "failed to get issue dependencies", + }, + { + name: "missing required parameter owner", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]interface{}{ + "method": "get_blocked_by", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter issue_number", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]interface{}{ + "method": "get_blocked_by", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedErrMsg: "missing required parameter: issue_number", + }, + { + name: "unknown method", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]interface{}{ + "method": "invalid_method", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "unknown method: invalid_method", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + if tc.expectedErrMsg != "" { + require.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedDependencies []IssueDependency + err = json.Unmarshal([]byte(textContent.Text), &returnedDependencies) + require.NoError(t, err) + require.Equal(t, len(tc.expectedDependencies), len(returnedDependencies)) + for i, expected := range tc.expectedDependencies { + assert.Equal(t, expected.ID, returnedDependencies[i].ID) + assert.Equal(t, expected.Number, returnedDependencies[i].Number) + assert.Equal(t, expected.Title, returnedDependencies[i].Title) + assert.Equal(t, expected.State, returnedDependencies[i].State) + assert.Equal(t, expected.HTMLURL, returnedDependencies[i].HTMLURL) + } + }) + } +} + +func Test_AddBlockedBy(t *testing.T) { + // Verify tool definition once + serverTool := IssueDependencyWrite(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "issue_dependency_write", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "blocking_issue_id") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number", "blocking_issue_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + }{ + { + name: "successful add blocked by", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, `{"message": "Dependency relationship created successfully"}`), + }), + requestArgs: map[string]interface{}{ + "method": "add", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "blocking_issue_id": float64(123), + }, + expectError: false, + }, + { + name: "issue not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), + requestArgs: map[string]interface{}{ + "method": "add", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + "blocking_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to add issue dependency", + }, + { + name: "blocking issue not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Blocking issue not found"}`), + }), + requestArgs: map[string]interface{}{ + "method": "add", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "blocking_issue_id": float64(999), + }, + expectError: false, + expectedErrMsg: "failed to add issue dependency", + }, + { + name: "validation failed - self blocking", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Issue cannot block itself"}]}`), + }), + requestArgs: map[string]interface{}{ + "method": "add", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "blocking_issue_id": float64(42), + }, + expectError: false, + expectedErrMsg: "failed to add issue dependency", + }, + { + name: "insufficient permissions", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + }), + requestArgs: map[string]interface{}{ + "method": "add", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "blocking_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to add issue dependency", + }, + { + name: "missing required parameter owner", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]interface{}{ + "method": "add", + "repo": "repo", + "issue_number": float64(42), + "blocking_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter blocking_issue_id", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]interface{}{ + "method": "add", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "missing required parameter: blocking_issue_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + if tc.expectedErrMsg != "" { + require.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and verify success message + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "Dependency relationship created successfully") + }) + } +} + +func Test_RemoveBlockedBy(t *testing.T) { + // Verify tool definition once (using same tool as AddBlockedBy) + serverTool := IssueDependencyWrite(translations.NullTranslationHelper) + tool := serverTool.Tool + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + }{ + { + name: "successful remove blocked by", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumberByIssueID: mockResponse(t, http.StatusNoContent, ""), + }), + requestArgs: map[string]interface{}{ + "method": "remove", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "blocking_issue_id": float64(123), + }, + expectError: false, + }, + { + name: "dependency not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumberByIssueID: mockResponse(t, http.StatusNotFound, `{"message": "Dependency not found"}`), + }), + requestArgs: map[string]interface{}{ + "method": "remove", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "blocking_issue_id": float64(999), + }, + expectError: false, + expectedErrMsg: "failed to remove issue dependency", + }, + { + name: "issue not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumberByIssueID: mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + }), + requestArgs: map[string]interface{}{ + "method": "remove", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + "blocking_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to remove issue dependency", + }, + { + name: "insufficient permissions", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesDependenciesBlockedByByOwnerByRepoByIssueNumberByIssueID: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + }), + requestArgs: map[string]interface{}{ + "method": "remove", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "blocking_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to remove issue dependency", + }, + { + name: "unknown method", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]interface{}{ + "method": "invalid_method", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "blocking_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "unknown method: invalid_method", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + if tc.expectedErrMsg != "" { + require.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and verify success message + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "Dependency relationship removed successfully") + }) + } + + // Verify that we're testing the same tool for both add and remove + assert.Equal(t, "issue_dependency_write", tool.Name) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index a169ff591..af884b989 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -195,6 +195,8 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { AddIssueComment(t), AssignCopilotToIssue(t), SubIssueWrite(t), + IssueDependencyRead(t), + IssueDependencyWrite(t), // User tools SearchUsers(t),