package github import ( "context" "encoding/json" "net/http" "strconv" "testing" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( endpointBlockedBy = EndpointPattern("GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by") endpointBlocking = EndpointPattern("GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocking") endpointAddBlock = EndpointPattern("POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by") endpointRemoveBlk = EndpointPattern("DELETE /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by/{issue_id}") endpointGetIssue = EndpointPattern("GET /repos/{owner}/{repo}/issues/{issue_number}") ) // jsonHandler writes the given status code and JSON-encoded body. func jsonHandler(status int, body any) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) _, _ = w.Write(MustMarshal(body)) } } func Test_IssueDependencyRead(t *testing.T) { // Verify tool definition once (flag-gated variant snap) serverTool := IssueDependencyRead(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueDependencies, tool)) require.Equal(t, FeatureFlagIssueDependencies, serverTool.FeatureFlagEnable) assert.Equal(t, "issue_dependency_read", tool.Name) assert.NotEmpty(t, tool.Description) assert.True(t, tool.Annotations.ReadOnlyHint) schema := tool.InputSchema.(*jsonschema.Schema) assert.Contains(t, schema.Properties, "method") assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "issue_number") assert.Contains(t, schema.Properties, "page") assert.Contains(t, schema.Properties, "perPage") assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "issue_number"}) blockedByIssues := []map[string]any{ { "number": 7, "title": "Blocker", "state": "open", "html_url": "https://github.com/owner/repo/issues/7", "repository_url": "https://api.github.com/repos/owner/repo", }, } blockingIssues := []map[string]any{ { "number": 8, "title": "Blocked A", "state": "open", "html_url": "https://github.com/owner/repo/issues/8", "repository_url": "https://api.github.com/repos/owner/repo", }, { "number": 9, "title": "Blocked B", "state": "closed", "html_url": "https://github.com/owner/repo/issues/9", "repository_url": "https://api.github.com/repos/owner/repo", }, } // A handler that also advertises a next page via the Link header. blockingHandler := func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Link", `; rel="next"`) w.WriteHeader(http.StatusOK) _, _ = w.Write(MustMarshal(blockingIssues)) } tests := []struct { name string method string option MockBackendOption expectedCount int expectedFirst int expectedState string expectedNext bool }{ { name: "get_blocked_by returns blockers", method: "get_blocked_by", option: WithRequestMatch(endpointBlockedBy, blockedByIssues), expectedCount: 1, expectedFirst: 7, expectedState: "OPEN", expectedNext: false, }, { name: "get_blocking returns blocked issues", method: "get_blocking", option: WithRequestMatchHandler(endpointBlocking, blockingHandler), expectedCount: 2, expectedFirst: 8, expectedState: "OPEN", expectedNext: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client := mustNewGHClient(t, NewMockedHTTPClient(tc.option)) deps := BaseDeps{Client: client} handler := serverTool.Handler(deps) request := createMCPRequest(map[string]any{ "method": tc.method, "owner": "owner", "repo": "repo", "issue_number": float64(123), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError, "expected result to not be an error") text := getTextResult(t, result) var payload struct { Issues []MinimalIssueRef `json:"issues"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` NextPage int `json:"nextPage"` } `json:"pageInfo"` } require.NoError(t, json.Unmarshal([]byte(text.Text), &payload)) require.Len(t, payload.Issues, tc.expectedCount) assert.Equal(t, tc.expectedFirst, payload.Issues[0].Number) assert.Equal(t, "owner/repo", payload.Issues[0].Repository) // State is normalized to upper case to match the GraphQL-sourced // state used by other MinimalIssueRef producers (e.g. get_parent). assert.Equal(t, tc.expectedState, payload.Issues[0].State) assert.Equal(t, tc.expectedNext, payload.PageInfo.HasNextPage) }) } } func Test_IssueDependencyRead_Errors(t *testing.T) { serverTool := IssueDependencyRead(translations.NullTranslationHelper) t.Run("missing required param", func(t *testing.T) { client := mustNewGHClient(t, NewMockedHTTPClient()) deps := BaseDeps{Client: client} handler := serverTool.Handler(deps) request := createMCPRequest(map[string]any{ "method": "get_blocked_by", "owner": "owner", "repo": "repo", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) getErrorResult(t, result) }) t.Run("API error is surfaced", func(t *testing.T) { client := mustNewGHClient(t, NewMockedHTTPClient( WithRequestMatchHandler(endpointBlockedBy, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) })), )) deps := BaseDeps{Client: client} handler := serverTool.Handler(deps) request := createMCPRequest(map[string]any{ "method": "get_blocked_by", "owner": "owner", "repo": "repo", "issue_number": float64(123), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) getErrorResult(t, result) }) } func Test_IssueDependencyWrite(t *testing.T) { // Verify tool definition once (flag-gated variant snap) serverTool := IssueDependencyWrite(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueDependencies, tool)) require.Equal(t, FeatureFlagIssueDependencies, serverTool.FeatureFlagEnable) assert.Equal(t, "issue_dependency_write", tool.Name) assert.NotEmpty(t, tool.Description) assert.False(t, tool.Annotations.ReadOnlyHint) schema := tool.InputSchema.(*jsonschema.Schema) assert.Contains(t, schema.Properties, "method") assert.Contains(t, schema.Properties, "type") assert.Contains(t, schema.Properties, "issue_number") assert.Contains(t, schema.Properties, "related_issue_number") assert.ElementsMatch(t, schema.Required, []string{"method", "type", "owner", "repo", "issue_number", "related_issue_number"}) // issue returned by the blocking-issue resolve GET; its id is what the // dependency endpoints operate on. resolvedIssue := func(number, id int) map[string]any { return map[string]any{ "id": id, "number": number, "title": "Resolved", "state": "open", "html_url": "https://github.com/owner/repo/issues/" + strconv.Itoa(number), "repository_url": "https://api.github.com/repos/owner/repo", } } // issue returned by the add/remove endpoints (the blocked issue). blockedIssue := func(number int) map[string]any { return map[string]any{ "number": number, "title": "Blocked", "state": "open", "html_url": "https://github.com/owner/repo/issues/" + strconv.Itoa(number), "repository_url": "https://api.github.com/repos/owner/repo", } } tests := []struct { name string method string relationship string options []MockBackendOption expectedMessage string expectedBlocked int expectedBlockng int }{ { name: "add blocked_by uses subject as blocked", method: "add", relationship: "blocked_by", // subject(1) is blocked by related(2): resolve related(2), block issue 1. options: []MockBackendOption{ WithRequestMatch(endpointGetIssue, resolvedIssue(2, 1002)), WithRequestMatchHandler(endpointAddBlock, jsonHandler(http.StatusCreated, blockedIssue(1))), }, expectedMessage: "dependency added", expectedBlocked: 1, expectedBlockng: 2, }, { name: "add blocking swaps roles", method: "add", relationship: "blocking", // subject(1) blocks related(2): resolve subject(1), block issue 2. options: []MockBackendOption{ WithRequestMatch(endpointGetIssue, resolvedIssue(1, 1001)), WithRequestMatchHandler(endpointAddBlock, jsonHandler(http.StatusCreated, blockedIssue(2))), }, expectedMessage: "dependency added", expectedBlocked: 2, expectedBlockng: 1, }, { name: "remove blocked_by", method: "remove", relationship: "blocked_by", options: []MockBackendOption{ WithRequestMatch(endpointGetIssue, resolvedIssue(2, 1002)), WithRequestMatch(endpointRemoveBlk, blockedIssue(1)), }, expectedMessage: "dependency removed", expectedBlocked: 1, expectedBlockng: 2, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client := mustNewGHClient(t, NewMockedHTTPClient(tc.options...)) deps := BaseDeps{Client: client} handler := serverTool.Handler(deps) request := createMCPRequest(map[string]any{ "method": tc.method, "type": tc.relationship, "owner": "owner", "repo": "repo", "issue_number": float64(1), "related_issue_number": float64(2), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError, "expected result to not be an error") text := getTextResult(t, result) var payload struct { Message string `json:"message"` BlockedIssue MinimalIssueRef `json:"blocked_issue"` BlockingIssue MinimalIssueRef `json:"blocking_issue"` } require.NoError(t, json.Unmarshal([]byte(text.Text), &payload)) assert.Equal(t, tc.expectedMessage, payload.Message) assert.Equal(t, tc.expectedBlocked, payload.BlockedIssue.Number) assert.Equal(t, tc.expectedBlockng, payload.BlockingIssue.Number) }) } t.Run("self dependency fails before any API call", func(t *testing.T) { // Register no handlers: the handler must return before resolving or mutating. client := mustNewGHClient(t, NewMockedHTTPClient()) deps := BaseDeps{Client: client} handler := serverTool.Handler(deps) request := createMCPRequest(map[string]any{ "method": "add", "type": "blocked_by", "owner": "owner", "repo": "repo", "issue_number": float64(1), "related_issue_number": float64(1), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError, "expected result to be an error") text := getTextResult(t, result) assert.Contains(t, text.Text, "itself") }) } func Test_IssueDependencyWrite_Validation(t *testing.T) { serverTool := IssueDependencyWrite(translations.NullTranslationHelper) cases := []struct { name string args map[string]any }{ { name: "unknown type", args: map[string]any{ "method": "add", "type": "related_to", "owner": "owner", "repo": "repo", "issue_number": float64(1), "related_issue_number": float64(2), }, }, { name: "missing related_issue_number", args: map[string]any{ "method": "add", "type": "blocked_by", "owner": "owner", "repo": "repo", "issue_number": float64(1), }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { client := mustNewGHClient(t, NewMockedHTTPClient()) deps := BaseDeps{Client: client} handler := serverTool.Handler(deps) request := createMCPRequest(tc.args) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) getErrorResult(t, result) }) } }