Files
2026-07-13 12:37:57 +08:00

373 lines
13 KiB
Go

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", `<https://api.github.com/repos/owner/repo/issues/123/dependencies/blocking?page=2>; 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)
})
}
}