chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user